Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ded7afb
git stash
0nko Sep 24, 2025
d43feb1
Add new feature flag and use it to choose SERP settings URL
0nko Sep 24, 2025
a8ebc0e
Revert "git stash"
0nko Sep 24, 2025
e7780b6
Fix duck.ai settings unit tests
0nko Sep 26, 2025
d3c0ee9
Fix a false warning
0nko Sep 26, 2025
931e396
Fix spotless issues
0nko Sep 26, 2025
4e7722f
Add CSS dependencies to the settings-impl module
0nko Sep 26, 2025
7e5506a
Update the duck.ai settings link handling
0nko Sep 26, 2025
dd5ae82
Add settings CSS handler and constants
0nko Sep 26, 2025
967c7e7
Add a VM to WebViewActivity
0nko Sep 26, 2025
8ff98d6
Fix spotless warnings
0nko Sep 26, 2025
0878647
Run the CSS handler on the IO dispatcher
0nko Sep 26, 2025
9d0ff68
Fix the back press deprecation and destroy the WebView on exit
0nko Sep 26, 2025
fe4495f
Reformat code
0nko Sep 26, 2025
207caa6
Fix formatting
0nko Sep 26, 2025
999f1b7
Create a custom webview activity for settings
0nko Sep 30, 2025
41cd743
Revert WebViewActivity changes
0nko Sep 30, 2025
ab3ccc4
Move the CoreContentScopeScript to the API module
0nko Sep 30, 2025
cd882f4
Use the WebViewCompatWrapper to inject CSS
0nko Sep 30, 2025
644281d
Fix spotless issues
0nko Sep 30, 2025
59e2e1d
Fix the test build
0nko Oct 1, 2025
2d110c3
Fix ktlint issues
0nko Oct 1, 2025
d16f1a4
Guard the private search behind a feature flag
0nko Oct 3, 2025
b4a6d38
Put the duck chat link opening behind a feature flag
0nko Oct 3, 2025
7cbbf5b
Move serpSettings constants to the impl module
0nko Oct 3, 2025
3f06e42
Remove the redundant webviewclient
0nko Oct 3, 2025
589314a
Add VM tests
0nko Oct 1, 2025
f1d7e5e
Add a test for feature flag being enabled
0nko Oct 1, 2025
3b1afd7
Fix unit tests
0nko Oct 6, 2025
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 @@ -34,6 +34,7 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.settings.api.SettingsPageFeature
import com.duckduckgo.settings.api.SettingsWebViewScreenWithParams
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
Expand Down Expand Up @@ -112,23 +113,27 @@ class PrivateSearchActivity : DuckDuckGoActivity() {
}

private fun launchCustomizeSearchWebPage() {
val settingsUrl =
if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) {
DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM
} else {
DUCKDUCKGO_SETTINGS_WEB_LINK
}
globalActivityStarter.start(
this,
WebViewActivityWithParams(
url = settingsUrl,
getString(R.string.privateSearchMoreSearchSettingsTitle),
),
)
if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) {
globalActivityStarter.start(
this,
SettingsWebViewScreenWithParams(
url = DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM,
getString(R.string.privateSearchMoreSearchSettingsTitle),
),
)
} else {
globalActivityStarter.start(
this,
WebViewActivityWithParams(
url = DUCKDUCKGO_SETTINGS_WEB_LINK,
getString(R.string.privateSearchMoreSearchSettingsTitle),
),
)
}
}

companion object {
private const val DUCKDUCKGO_SETTINGS_WEB_LINK = "https://duckduckgo.com/settings"
private const val DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?return=privateSearch"
private const val DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?ko=-1&return=privateSearch"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 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.api

import com.duckduckgo.feature.toggles.api.Toggle

interface CoreContentScopeScripts {
fun getScript(
isDesktopMode: Boolean?,
activeExperiments: List<Toggle>,
): String

fun isEnabled(): Boolean

val secret: String
val javascriptInterface: String
val callbackName: String
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.duckduckgo.contentscopescripts.impl
import android.webkit.WebView
import com.duckduckgo.app.global.model.Site
import com.duckduckgo.browser.api.JsInjectorPlugin
import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.Toggle
import com.squareup.anvil.annotations.ContributesMultibinding
Expand All @@ -39,7 +40,11 @@ class ContentScopeScriptsJsInjectorPlugin @Inject constructor(
}
}

override fun onPageFinished(webView: WebView, url: String?, site: Site?) {
override fun onPageFinished(
webView: WebView,
url: String?,
site: Site?,
) {
// NOOP
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.appbuildconfig.api.isInternalBuild
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin
import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.FeatureException
import com.duckduckgo.feature.toggles.api.Toggle
Expand All @@ -37,19 +38,6 @@ import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject

interface CoreContentScopeScripts {
fun getScript(
isDesktopMode: Boolean?,
activeExperiments: List<Toggle>,
): String

fun isEnabled(): Boolean

val secret: String
val javascriptInterface: String
val callbackName: String
}

@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class)
class RealContentScopeScripts @Inject constructor(
Expand All @@ -61,16 +49,15 @@ class RealContentScopeScripts @Inject constructor(
private val fingerprintProtectionManager: FingerprintProtectionManager,
private val contentScopeScriptsFeature: ContentScopeScriptsFeature,
) : CoreContentScopeScripts {

private var cachedContentScopeJson: String = getContentScopeJson("", emptyList())

private var cachedUserUnprotectedDomains = CopyOnWriteArrayList<String>()
private var cachedUserUnprotectedDomainsJson: String = emptyJsonList
private var cachedUserUnprotectedDomainsJson: String = EMPTY_JSON_LIST

private var cachedUserPreferencesJson: String = emptyJson
private var cachedUserPreferencesJson: String = EMPTY_JSON

private var cachedUnprotectTemporaryExceptions = CopyOnWriteArrayList<FeatureException>()
private var cachedUnprotectTemporaryExceptionsJson: String = emptyJsonList
private var cachedUnprotectTemporaryExceptionsJson: String = EMPTY_JSON_LIST

private lateinit var cachedContentScopeJS: String

Expand Down Expand Up @@ -114,12 +101,12 @@ class RealContentScopeScripts @Inject constructor(
return cachedContentScopeJS
}

override fun isEnabled(): Boolean {
return contentScopeScriptsFeature.self().isEnabled()
}
override fun isEnabled(): Boolean = contentScopeScriptsFeature.self().isEnabled()

private fun getSecretKeyValuePair() = "\"messageSecret\":\"$secret\""

private fun getCallbackKeyValuePair() = "\"messageCallback\":\"$callbackName\""

private fun getInterfaceKeyValuePair() = "\"javascriptInterface\":\"$javascriptInterface\""

private fun getPluginParameters(): PluginParameters {
Expand All @@ -145,7 +132,7 @@ class RealContentScopeScripts @Inject constructor(
private fun cacheUserUnprotectedDomains(userUnprotectedDomains: List<String>) {
cachedUserUnprotectedDomains.clear()
if (userUnprotectedDomains.isEmpty()) {
cachedUserUnprotectedDomainsJson = emptyJsonList
cachedUserUnprotectedDomainsJson = EMPTY_JSON_LIST
} else {
cachedUserUnprotectedDomainsJson = getUserUnprotectedDomainsJson(userUnprotectedDomains)
cachedUserUnprotectedDomains.addAll(userUnprotectedDomains)
Expand All @@ -155,7 +142,7 @@ class RealContentScopeScripts @Inject constructor(
private fun cacheUserUnprotectedTemporaryExceptions(unprotectedTemporaryExceptions: List<FeatureException>) {
cachedUnprotectTemporaryExceptions.clear()
if (unprotectedTemporaryExceptions.isEmpty()) {
cachedUnprotectTemporaryExceptionsJson = emptyJsonList
cachedUnprotectTemporaryExceptionsJson = EMPTY_JSON_LIST
} else {
cachedUnprotectTemporaryExceptionsJson = getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)
cachedUnprotectTemporaryExceptions.addAll(unprotectedTemporaryExceptions)
Expand All @@ -165,11 +152,12 @@ class RealContentScopeScripts @Inject constructor(
private fun cacheContentScopeJS() {
val contentScopeJS = contentScopeJSReader.getContentScopeJS()

cachedContentScopeJS = contentScopeJS
.replace(contentScope, cachedContentScopeJson)
.replace(userUnprotectedDomains, cachedUserUnprotectedDomainsJson)
.replace(userPreferences, cachedUserPreferencesJson)
.replace(messagingParameters, "${getSecretKeyValuePair()},${getCallbackKeyValuePair()},${getInterfaceKeyValuePair()}")
cachedContentScopeJS =
contentScopeJS
.replace(CONTENT_SCOPE, cachedContentScopeJson)
.replace(USER_UNPROTECTED_DOMAINS, cachedUserUnprotectedDomainsJson)
.replace(USER_PREFERENCES, cachedUserPreferencesJson)
.replace(MESSAGING_PARAMETERS, "${getSecretKeyValuePair()},${getCallbackKeyValuePair()},${getInterfaceKeyValuePair()}")
}

private fun getUserUnprotectedDomainsJson(userUnprotectedDomains: List<String>): String {
Expand All @@ -192,19 +180,25 @@ class RealContentScopeScripts @Inject constructor(
activeExperiments: List<Toggle>,
): String {
val experiments = getExperimentsKeyValuePair(activeExperiments)
val defaultParameters = "${getVersionNumberKeyValuePair()},${getPlatformKeyValuePair()},${getLanguageKeyValuePair()}," +
"${getSessionKeyValuePair()},${getDesktopModeKeyValuePair(isDesktopMode ?: false)},$messagingParameters"
val defaultParameters =
"${getVersionNumberKeyValuePair()},${getPlatformKeyValuePair()},${getLanguageKeyValuePair()}," +
"${getSessionKeyValuePair()},${getDesktopModeKeyValuePair(isDesktopMode ?: false)},$MESSAGING_PARAMETERS"
if (userPreferences.isEmpty()) {
return "{$experiments,$defaultParameters}"
}
return "{$userPreferences,$experiments,$defaultParameters}"
}

private fun getVersionNumberKeyValuePair() = "\"versionNumber\":${appBuildConfig.versionCode}"

private fun getPlatformKeyValuePair() = "\"platform\":{\"name\":\"android\",\"internal\":${appBuildConfig.isInternalBuild()}}"

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<Toggle>): String {
return runBlocking {
val type = Types.newParameterizedType(List::class.java, Experiment::class.java)
Expand All @@ -224,21 +218,23 @@ class RealContentScopeScripts @Inject constructor(
}
}

private fun getContentScopeJson(config: String, unprotectedTemporaryExceptions: List<FeatureException>): String = (
"{\"features\":{$config},\"unprotectedTemporary\":${getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)}}"
)
private fun getContentScopeJson(
config: String,
unprotectedTemporaryExceptions: List<FeatureException>,
): 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("-", "")
}
const val EMPTY_JSON_LIST = "[]"
const val EMPTY_JSON = "{}"
const val CONTENT_SCOPE = "\$CONTENT_SCOPE$"
const val USER_UNPROTECTED_DOMAINS = "\$USER_UNPROTECTED_DOMAINS$"
const val USER_PREFERENCES = "\$USER_PREFERENCES$"
const val MESSAGING_PARAMETERS = "\$ANDROID_MESSAGING_PARAMETERS$"

private fun getSecret(): String = UUID.randomUUID().toString().replace("-", "")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import androidx.core.net.toUri
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin
import com.duckduckgo.contentscopescripts.impl.CoreContentScopeScripts
import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.js.messaging.api.JsCallbackData
import com.duckduckgo.js.messaging.api.JsMessage
Expand All @@ -49,7 +49,6 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
private val coreContentScopeScripts: CoreContentScopeScripts,
private val handlers: PluginPoint<ContentScopeJsMessageHandlersPlugin>,
) : JsMessaging {

private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build()

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

@JavascriptInterface
override fun process(message: String, secret: String) {
override fun process(
message: String,
secret: String,
) {
try {
val adapter = moshi.adapter(JsMessage::class.java)
val jsMessage = adapter.fromJson(message)
val domain = runBlocking(dispatcherProvider.main()) {
webView.url?.toUri()?.host
}
val domain =
runBlocking(dispatcherProvider.main()) {
webView.url?.toUri()?.host
}
jsMessage?.let {
if (this.secret == secret && context == jsMessage.context && (allowedDomains.isEmpty() || allowedDomains.contains(domain))) {
if (jsMessage.method == "addDebugFlag") {
Expand All @@ -79,44 +82,52 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
data = jsMessage.params,
)
}
handlers.getPlugins().map { it.getJsMessageHandler() }.firstOrNull {
it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName &&
(it.allowedDomains.isEmpty() || it.allowedDomains.contains(domain))
}?.process(jsMessage, this, jsMessageCallback)
handlers
.getPlugins()
.map { it.getJsMessageHandler() }
.firstOrNull {
it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName &&
(it.allowedDomains.isEmpty() || it.allowedDomains.contains(domain))
}?.process(jsMessage, this, jsMessageCallback)
}
}
} catch (e: Exception) {
logcat(ERROR) { "Exception is ${e.asLog()}" }
}
}

override fun register(webView: WebView, jsMessageCallback: JsMessageCallback?) {
override fun register(
webView: WebView,
jsMessageCallback: JsMessageCallback?,
) {
if (jsMessageCallback == null) throw Exception("Callback cannot be null")
this.webView = webView
this.jsMessageCallback = jsMessageCallback
this.webView.addJavascriptInterface(this, coreContentScopeScripts.javascriptInterface)
}

override fun sendSubscriptionEvent(subscriptionEventData: SubscriptionEventData) {
val subscriptionEvent = SubscriptionEvent(
context,
subscriptionEventData.featureName,
subscriptionEventData.subscriptionName,
subscriptionEventData.params,
)
val subscriptionEvent =
SubscriptionEvent(
context,
subscriptionEventData.featureName,
subscriptionEventData.subscriptionName,
subscriptionEventData.params,
)
if (::webView.isInitialized) {
jsMessageHelper.sendSubscriptionEvent(subscriptionEvent, callbackName, secret, webView)
}
}

override fun onResponse(response: JsCallbackData) {
val jsResponse = JsRequestResponse.Success(
context = context,
featureName = response.featureName,
method = response.method,
id = response.id,
result = response.params,
)
val jsResponse =
JsRequestResponse.Success(
context = context,
featureName = response.featureName,
method = response.method,
id = response.id,
result = response.params,
)
jsMessageHelper.sendJsResponse(jsResponse, callbackName, secret, webView)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package com.duckduckgo.contentscopescripts.impl.messaging

import android.annotation.SuppressLint
import android.webkit.WebView
import com.duckduckgo.contentscopescripts.impl.CoreContentScopeScripts
import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts
import com.duckduckgo.contentscopescripts.impl.WebViewCompatContentScopeScripts
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.js.messaging.api.JsMessageHelper
Expand Down
Loading
Loading