Skip to content

Commit 98cd8ea

Browse files
authored
Save and Exit: Add CSS handler (#6853)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1202552961248957/task/1211418992991767 ### Description This PR updates how the duck.ai SERP settings are opened (in a custom WebView activity) and adds the CSS handler. ### Steps to test this PR _Private Search_ - [ ] Go to Settings -> Private Search - [ ] Tap on the More Search Settings - [ ] Notice a custom WebView screen with a toolbar is opened - [ ] Change some setting - [ ] Scroll down and tap on `Save and Exit` button - [ ] Notice the WebView screen is closed and you're back in the native Private Search settings - [ ] Tap on the More Search Settings again - [ ] Notice the settings change was preserved - [ ] Tap on the back button - [ ] Notice the WebView screen is closed _AI Features_ - [ ] Go to Settings -> AI Features - [ ] Tap on the Search Assist Settings - [ ] Notice a custom WebView screen with a toolbar is opened (title says "Search Assist Settings") - [ ] Change some setting - [ ] Scroll down and tap on `Save and Exit` button - [ ] Notice the WebView screen is closed and you're back in the native AI Features settings - [ ] Tap on the Search Assist Settings again - [ ] Notice the settings change was preserved - [ ] Tap on the back button - [ ] Notice the WebView screen is closed
1 parent 39eb56d commit 98cd8ea

File tree

21 files changed

+823
-233
lines changed

21 files changed

+823
-233
lines changed

app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding
3434
import com.duckduckgo.di.scopes.ActivityScope
3535
import com.duckduckgo.navigation.api.GlobalActivityStarter
3636
import com.duckduckgo.settings.api.SettingsPageFeature
37+
import com.duckduckgo.settings.api.SettingsWebViewScreenWithParams
3738
import kotlinx.coroutines.flow.launchIn
3839
import kotlinx.coroutines.flow.onEach
3940
import javax.inject.Inject
@@ -112,23 +113,27 @@ class PrivateSearchActivity : DuckDuckGoActivity() {
112113
}
113114

114115
private fun launchCustomizeSearchWebPage() {
115-
val settingsUrl =
116-
if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) {
117-
DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM
118-
} else {
119-
DUCKDUCKGO_SETTINGS_WEB_LINK
120-
}
121-
globalActivityStarter.start(
122-
this,
123-
WebViewActivityWithParams(
124-
url = settingsUrl,
125-
getString(R.string.privateSearchMoreSearchSettingsTitle),
126-
),
127-
)
116+
if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) {
117+
globalActivityStarter.start(
118+
this,
119+
SettingsWebViewScreenWithParams(
120+
url = DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM,
121+
getString(R.string.privateSearchMoreSearchSettingsTitle),
122+
),
123+
)
124+
} else {
125+
globalActivityStarter.start(
126+
this,
127+
WebViewActivityWithParams(
128+
url = DUCKDUCKGO_SETTINGS_WEB_LINK,
129+
getString(R.string.privateSearchMoreSearchSettingsTitle),
130+
),
131+
)
132+
}
128133
}
129134

130135
companion object {
131136
private const val DUCKDUCKGO_SETTINGS_WEB_LINK = "https://duckduckgo.com/settings"
132-
private const val DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?return=privateSearch"
137+
private const val DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?ko=-1&return=privateSearch"
133138
}
134139
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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.contentscopescripts.api
18+
19+
import com.duckduckgo.feature.toggles.api.Toggle
20+
21+
interface CoreContentScopeScripts {
22+
fun getScript(
23+
isDesktopMode: Boolean?,
24+
activeExperiments: List<Toggle>,
25+
): String
26+
27+
fun isEnabled(): Boolean
28+
29+
val secret: String
30+
val javascriptInterface: String
31+
val callbackName: String
32+
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package com.duckduckgo.contentscopescripts.impl
1919
import android.webkit.WebView
2020
import com.duckduckgo.app.global.model.Site
2121
import com.duckduckgo.browser.api.JsInjectorPlugin
22+
import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts
2223
import com.duckduckgo.di.scopes.AppScope
2324
import com.duckduckgo.feature.toggles.api.Toggle
2425
import com.squareup.anvil.annotations.ContributesMultibinding
@@ -39,7 +40,11 @@ class ContentScopeScriptsJsInjectorPlugin @Inject constructor(
3940
}
4041
}
4142

42-
override fun onPageFinished(webView: WebView, url: String?, site: Site?) {
43+
override fun onPageFinished(
44+
webView: WebView,
45+
url: String?,
46+
site: Site?,
47+
) {
4348
// NOOP
4449
}
4550
}

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

Lines changed: 38 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig
2121
import com.duckduckgo.appbuildconfig.api.isInternalBuild
2222
import com.duckduckgo.common.utils.plugins.PluginPoint
2323
import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin
24+
import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts
2425
import com.duckduckgo.di.scopes.AppScope
2526
import com.duckduckgo.feature.toggles.api.FeatureException
2627
import com.duckduckgo.feature.toggles.api.Toggle
@@ -37,19 +38,6 @@ import java.util.UUID
3738
import java.util.concurrent.CopyOnWriteArrayList
3839
import javax.inject.Inject
3940

40-
interface CoreContentScopeScripts {
41-
fun getScript(
42-
isDesktopMode: Boolean?,
43-
activeExperiments: List<Toggle>,
44-
): String
45-
46-
fun isEnabled(): Boolean
47-
48-
val secret: String
49-
val javascriptInterface: String
50-
val callbackName: String
51-
}
52-
5341
@SingleInstanceIn(AppScope::class)
5442
@ContributesBinding(AppScope::class)
5543
class RealContentScopeScripts @Inject constructor(
@@ -61,16 +49,15 @@ class RealContentScopeScripts @Inject constructor(
6149
private val fingerprintProtectionManager: FingerprintProtectionManager,
6250
private val contentScopeScriptsFeature: ContentScopeScriptsFeature,
6351
) : CoreContentScopeScripts {
64-
6552
private var cachedContentScopeJson: String = getContentScopeJson("", emptyList())
6653

6754
private var cachedUserUnprotectedDomains = CopyOnWriteArrayList<String>()
68-
private var cachedUserUnprotectedDomainsJson: String = emptyJsonList
55+
private var cachedUserUnprotectedDomainsJson: String = EMPTY_JSON_LIST
6956

70-
private var cachedUserPreferencesJson: String = emptyJson
57+
private var cachedUserPreferencesJson: String = EMPTY_JSON
7158

7259
private var cachedUnprotectTemporaryExceptions = CopyOnWriteArrayList<FeatureException>()
73-
private var cachedUnprotectTemporaryExceptionsJson: String = emptyJsonList
60+
private var cachedUnprotectTemporaryExceptionsJson: String = EMPTY_JSON_LIST
7461

7562
private lateinit var cachedContentScopeJS: String
7663

@@ -114,12 +101,12 @@ class RealContentScopeScripts @Inject constructor(
114101
return cachedContentScopeJS
115102
}
116103

117-
override fun isEnabled(): Boolean {
118-
return contentScopeScriptsFeature.self().isEnabled()
119-
}
104+
override fun isEnabled(): Boolean = contentScopeScriptsFeature.self().isEnabled()
120105

121106
private fun getSecretKeyValuePair() = "\"messageSecret\":\"$secret\""
107+
122108
private fun getCallbackKeyValuePair() = "\"messageCallback\":\"$callbackName\""
109+
123110
private fun getInterfaceKeyValuePair() = "\"javascriptInterface\":\"$javascriptInterface\""
124111

125112
private fun getPluginParameters(): PluginParameters {
@@ -145,7 +132,7 @@ class RealContentScopeScripts @Inject constructor(
145132
private fun cacheUserUnprotectedDomains(userUnprotectedDomains: List<String>) {
146133
cachedUserUnprotectedDomains.clear()
147134
if (userUnprotectedDomains.isEmpty()) {
148-
cachedUserUnprotectedDomainsJson = emptyJsonList
135+
cachedUserUnprotectedDomainsJson = EMPTY_JSON_LIST
149136
} else {
150137
cachedUserUnprotectedDomainsJson = getUserUnprotectedDomainsJson(userUnprotectedDomains)
151138
cachedUserUnprotectedDomains.addAll(userUnprotectedDomains)
@@ -155,7 +142,7 @@ class RealContentScopeScripts @Inject constructor(
155142
private fun cacheUserUnprotectedTemporaryExceptions(unprotectedTemporaryExceptions: List<FeatureException>) {
156143
cachedUnprotectTemporaryExceptions.clear()
157144
if (unprotectedTemporaryExceptions.isEmpty()) {
158-
cachedUnprotectTemporaryExceptionsJson = emptyJsonList
145+
cachedUnprotectTemporaryExceptionsJson = EMPTY_JSON_LIST
159146
} else {
160147
cachedUnprotectTemporaryExceptionsJson = getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)
161148
cachedUnprotectTemporaryExceptions.addAll(unprotectedTemporaryExceptions)
@@ -165,11 +152,12 @@ class RealContentScopeScripts @Inject constructor(
165152
private fun cacheContentScopeJS() {
166153
val contentScopeJS = contentScopeJSReader.getContentScopeJS()
167154

168-
cachedContentScopeJS = contentScopeJS
169-
.replace(contentScope, cachedContentScopeJson)
170-
.replace(userUnprotectedDomains, cachedUserUnprotectedDomainsJson)
171-
.replace(userPreferences, cachedUserPreferencesJson)
172-
.replace(messagingParameters, "${getSecretKeyValuePair()},${getCallbackKeyValuePair()},${getInterfaceKeyValuePair()}")
155+
cachedContentScopeJS =
156+
contentScopeJS
157+
.replace(CONTENT_SCOPE, cachedContentScopeJson)
158+
.replace(USER_UNPROTECTED_DOMAINS, cachedUserUnprotectedDomainsJson)
159+
.replace(USER_PREFERENCES, cachedUserPreferencesJson)
160+
.replace(MESSAGING_PARAMETERS, "${getSecretKeyValuePair()},${getCallbackKeyValuePair()},${getInterfaceKeyValuePair()}")
173161
}
174162

175163
private fun getUserUnprotectedDomainsJson(userUnprotectedDomains: List<String>): String {
@@ -192,19 +180,25 @@ class RealContentScopeScripts @Inject constructor(
192180
activeExperiments: List<Toggle>,
193181
): String {
194182
val experiments = getExperimentsKeyValuePair(activeExperiments)
195-
val defaultParameters = "${getVersionNumberKeyValuePair()},${getPlatformKeyValuePair()},${getLanguageKeyValuePair()}," +
196-
"${getSessionKeyValuePair()},${getDesktopModeKeyValuePair(isDesktopMode ?: false)},$messagingParameters"
183+
val defaultParameters =
184+
"${getVersionNumberKeyValuePair()},${getPlatformKeyValuePair()},${getLanguageKeyValuePair()}," +
185+
"${getSessionKeyValuePair()},${getDesktopModeKeyValuePair(isDesktopMode ?: false)},$MESSAGING_PARAMETERS"
197186
if (userPreferences.isEmpty()) {
198187
return "{$experiments,$defaultParameters}"
199188
}
200189
return "{$userPreferences,$experiments,$defaultParameters}"
201190
}
202191

203192
private fun getVersionNumberKeyValuePair() = "\"versionNumber\":${appBuildConfig.versionCode}"
193+
204194
private fun getPlatformKeyValuePair() = "\"platform\":{\"name\":\"android\",\"internal\":${appBuildConfig.isInternalBuild()}}"
195+
205196
private fun getLanguageKeyValuePair() = "\"locale\":\"${Locale.getDefault().language}\""
197+
206198
private fun getDesktopModeKeyValuePair(isDesktopMode: Boolean) = "\"desktopModeEnabled\":$isDesktopMode"
199+
207200
private fun getSessionKeyValuePair() = "\"sessionKey\":\"${fingerprintProtectionManager.getSeed()}\""
201+
208202
private fun getExperimentsKeyValuePair(activeExperiments: List<Toggle>): String {
209203
return runBlocking {
210204
val type = Types.newParameterizedType(List::class.java, Experiment::class.java)
@@ -224,21 +218,23 @@ class RealContentScopeScripts @Inject constructor(
224218
}
225219
}
226220

227-
private fun getContentScopeJson(config: String, unprotectedTemporaryExceptions: List<FeatureException>): String = (
228-
"{\"features\":{$config},\"unprotectedTemporary\":${getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)}}"
229-
)
221+
private fun getContentScopeJson(
222+
config: String,
223+
unprotectedTemporaryExceptions: List<FeatureException>,
224+
): String =
225+
(
226+
"{\"features\":{$config},\"unprotectedTemporary\":${getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)}}"
227+
)
230228

231229
companion object {
232-
const val emptyJsonList = "[]"
233-
const val emptyJson = "{}"
234-
const val contentScope = "\$CONTENT_SCOPE$"
235-
const val userUnprotectedDomains = "\$USER_UNPROTECTED_DOMAINS$"
236-
const val userPreferences = "\$USER_PREFERENCES$"
237-
const val messagingParameters = "\$ANDROID_MESSAGING_PARAMETERS$"
238-
239-
private fun getSecret(): String {
240-
return UUID.randomUUID().toString().replace("-", "")
241-
}
230+
const val EMPTY_JSON_LIST = "[]"
231+
const val EMPTY_JSON = "{}"
232+
const val CONTENT_SCOPE = "\$CONTENT_SCOPE$"
233+
const val USER_UNPROTECTED_DOMAINS = "\$USER_UNPROTECTED_DOMAINS$"
234+
const val USER_PREFERENCES = "\$USER_PREFERENCES$"
235+
const val MESSAGING_PARAMETERS = "\$ANDROID_MESSAGING_PARAMETERS$"
236+
237+
private fun getSecret(): String = UUID.randomUUID().toString().replace("-", "")
242238
}
243239
}
244240

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

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import androidx.core.net.toUri
2222
import com.duckduckgo.common.utils.DispatcherProvider
2323
import com.duckduckgo.common.utils.plugins.PluginPoint
2424
import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin
25-
import com.duckduckgo.contentscopescripts.impl.CoreContentScopeScripts
25+
import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts
2626
import com.duckduckgo.di.scopes.ActivityScope
2727
import com.duckduckgo.js.messaging.api.JsCallbackData
2828
import com.duckduckgo.js.messaging.api.JsMessage
@@ -49,7 +49,6 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
4949
private val coreContentScopeScripts: CoreContentScopeScripts,
5050
private val handlers: PluginPoint<ContentScopeJsMessageHandlersPlugin>,
5151
) : JsMessaging {
52-
5352
private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build()
5453

5554
private lateinit var webView: WebView
@@ -61,13 +60,17 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
6160
override val allowedDomains: List<String> = emptyList()
6261

6362
@JavascriptInterface
64-
override fun process(message: String, secret: String) {
63+
override fun process(
64+
message: String,
65+
secret: String,
66+
) {
6567
try {
6668
val adapter = moshi.adapter(JsMessage::class.java)
6769
val jsMessage = adapter.fromJson(message)
68-
val domain = runBlocking(dispatcherProvider.main()) {
69-
webView.url?.toUri()?.host
70-
}
70+
val domain =
71+
runBlocking(dispatcherProvider.main()) {
72+
webView.url?.toUri()?.host
73+
}
7174
jsMessage?.let {
7275
if (this.secret == secret && context == jsMessage.context && (allowedDomains.isEmpty() || allowedDomains.contains(domain))) {
7376
if (jsMessage.method == "addDebugFlag") {
@@ -79,44 +82,52 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
7982
data = jsMessage.params,
8083
)
8184
}
82-
handlers.getPlugins().map { it.getJsMessageHandler() }.firstOrNull {
83-
it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName &&
84-
(it.allowedDomains.isEmpty() || it.allowedDomains.contains(domain))
85-
}?.process(jsMessage, this, jsMessageCallback)
85+
handlers
86+
.getPlugins()
87+
.map { it.getJsMessageHandler() }
88+
.firstOrNull {
89+
it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName &&
90+
(it.allowedDomains.isEmpty() || it.allowedDomains.contains(domain))
91+
}?.process(jsMessage, this, jsMessageCallback)
8692
}
8793
}
8894
} catch (e: Exception) {
8995
logcat(ERROR) { "Exception is ${e.asLog()}" }
9096
}
9197
}
9298

93-
override fun register(webView: WebView, jsMessageCallback: JsMessageCallback?) {
99+
override fun register(
100+
webView: WebView,
101+
jsMessageCallback: JsMessageCallback?,
102+
) {
94103
if (jsMessageCallback == null) throw Exception("Callback cannot be null")
95104
this.webView = webView
96105
this.jsMessageCallback = jsMessageCallback
97106
this.webView.addJavascriptInterface(this, coreContentScopeScripts.javascriptInterface)
98107
}
99108

100109
override fun sendSubscriptionEvent(subscriptionEventData: SubscriptionEventData) {
101-
val subscriptionEvent = SubscriptionEvent(
102-
context,
103-
subscriptionEventData.featureName,
104-
subscriptionEventData.subscriptionName,
105-
subscriptionEventData.params,
106-
)
110+
val subscriptionEvent =
111+
SubscriptionEvent(
112+
context,
113+
subscriptionEventData.featureName,
114+
subscriptionEventData.subscriptionName,
115+
subscriptionEventData.params,
116+
)
107117
if (::webView.isInitialized) {
108118
jsMessageHelper.sendSubscriptionEvent(subscriptionEvent, callbackName, secret, webView)
109119
}
110120
}
111121

112122
override fun onResponse(response: JsCallbackData) {
113-
val jsResponse = JsRequestResponse.Success(
114-
context = context,
115-
featureName = response.featureName,
116-
method = response.method,
117-
id = response.id,
118-
result = response.params,
119-
)
123+
val jsResponse =
124+
JsRequestResponse.Success(
125+
context = context,
126+
featureName = response.featureName,
127+
method = response.method,
128+
id = response.id,
129+
result = response.params,
130+
)
120131
jsMessageHelper.sendJsResponse(jsResponse, callbackName, secret, webView)
121132
}
122133
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ package com.duckduckgo.contentscopescripts.impl.messaging
1818

1919
import android.annotation.SuppressLint
2020
import android.webkit.WebView
21-
import com.duckduckgo.contentscopescripts.impl.CoreContentScopeScripts
21+
import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts
2222
import com.duckduckgo.contentscopescripts.impl.WebViewCompatContentScopeScripts
2323
import com.duckduckgo.di.scopes.FragmentScope
2424
import com.duckduckgo.js.messaging.api.JsMessageHelper

0 commit comments

Comments
 (0)