From ded7afb7f4f6d77788e190f0ebbd7a704de33df4 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 24 Sep 2025 14:24:44 +0200 Subject: [PATCH 01/29] git stash --- .../SettingsContentScopeJsMessageHandler.kt | 47 ++++++++++++ .../settings/messaging/SettingsJSHelper.kt | 76 +++++++++++++++++++ .../impl/RealPrivacyConfigDownloader.kt | 1 + 3 files changed, 124 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsContentScopeJsMessageHandler.kt create mode 100644 app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsJSHelper.kt diff --git a/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsContentScopeJsMessageHandler.kt b/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsContentScopeJsMessageHandler.kt new file mode 100644 index 000000000000..d225d94a843b --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsContentScopeJsMessageHandler.kt @@ -0,0 +1,47 @@ +/* + * 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.app.settings.messaging + +import com.duckduckgo.app.settings.messaging.RealSettingsJSHelper.Companion.METHOD_OPEN_NATIVE_SETTINGS +import com.duckduckgo.app.settings.messaging.RealSettingsJSHelper.Companion.SERP_SETTINGS_FEATURE_NAME +import com.duckduckgo.common.utils.AppUrl +import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessageCallback +import com.duckduckgo.js.messaging.api.JsMessageHandler +import com.duckduckgo.js.messaging.api.JsMessaging +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class SettingsContentScopeJsMessageHandler @Inject constructor() : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = object : JsMessageHandler { + override fun process(jsMessage: JsMessage, jsMessaging: JsMessaging, jsMessageCallback: JsMessageCallback?) { + jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id ?: "", jsMessage.params) + } + + override val allowedDomains: List = listOf( + AppUrl.Url.HOST, + ) + + override val featureName: String = SERP_SETTINGS_FEATURE_NAME + override val methods: List = listOf( + METHOD_OPEN_NATIVE_SETTINGS, + ) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsJSHelper.kt b/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsJSHelper.kt new file mode 100644 index 000000000000..4f4b98b06e14 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsJSHelper.kt @@ -0,0 +1,76 @@ +/* + * 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.app.settings.messaging + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import logcat.LogPriority +import logcat.logcat +import org.json.JSONObject + +interface SettingsJSHelper { + suspend fun processJsCallbackMessage( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ): JsCallbackData? +} + +@ContributesBinding(AppScope::class) +class RealSettingsJSHelper @Inject constructor() : SettingsJSHelper { + + override suspend fun processJsCallbackMessage( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ): JsCallbackData? = when (method) { + METHOD_OPEN_NATIVE_SETTINGS -> { + val returnTo = data?.optString(PARAM_RETURN) + openNativeSettings(returnTo) + null + } + else -> null + } + + private fun openNativeSettings( + returnTo: String?, + ) { + when (returnTo) { + ID_AI_FEATURES_SETTINGS -> { + logcat { "Return: $returnTo" } + } + ID_PRIVATE_SEARCH_SETTINGS -> { + logcat { "Return: $returnTo" } + } + else -> { + logcat(LogPriority.WARN) { "Unknown settings return value: $returnTo" } + } + } + } + + companion object { + const val SERP_SETTINGS_FEATURE_NAME = "serpSettings" + const val METHOD_OPEN_NATIVE_SETTINGS = "openNativeSettings" + private const val ID_AI_FEATURES_SETTINGS = "aiFeatures" + private const val ID_PRIVATE_SEARCH_SETTINGS = "privateSearch" + private const val PARAM_RETURN = "return" + } +} diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt index 22ebe888922a..ed92c167b1f3 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt @@ -60,6 +60,7 @@ class RealPrivacyConfigDownloader @Inject constructor( override suspend fun download(): PrivacyConfigDownloader.ConfigDownloadResult { logcat { "Downloading privacy config" } + return Error(null) // Default to error state, only return success at the end of the method when everything has worked val response = runCatching { privacyConfigService.privacyConfig() From d43feb1bd2322abef9a280a8c5be3c2fd22a0a91 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 24 Sep 2025 14:42:30 +0200 Subject: [PATCH 02/29] Add new feature flag and use it to choose SERP settings URL --- .../app/privatesearch/PrivateSearchActivity.kt | 11 +++++------ .../impl/ui/settings/DuckChatSettingsViewModel.kt | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt index c23c952ed2e3..0ad37cf1da57 100644 --- a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt @@ -112,12 +112,11 @@ class PrivateSearchActivity : DuckDuckGoActivity() { } private fun launchCustomizeSearchWebPage() { - val settingsUrl = - if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { - DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM - } else { - DUCKDUCKGO_SETTINGS_WEB_LINK - } + val settingsUrl = if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM + } else { + DUCKDUCKGO_SETTINGS_WEB_LINK + } globalActivityStarter.start( this, WebViewActivityWithParams( diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt index 7b9cda37650e..ea9548f8ecc8 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt @@ -126,12 +126,11 @@ class DuckChatSettingsViewModel @Inject constructor( fun duckChatSearchAISettingsClicked() { viewModelScope.launch { - val settingsLink = - if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM - } else { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK - } + val settingsLink = if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM + } else { + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK + } commandChannel.send(OpenLinkInNewTab(settingsLink)) pixel.fire(DuckChatPixelName.DUCK_CHAT_SEARCH_ASSIST_SETTINGS_BUTTON_CLICKED) } From a8ebc0edebb68487e9e08151b4477be569ad0018 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 24 Sep 2025 14:50:40 +0200 Subject: [PATCH 03/29] Revert "git stash" This reverts commit 04a1c4a2a14c66544545b4d70f77ada0d92a3e79. --- .../SettingsContentScopeJsMessageHandler.kt | 47 ------------ .../settings/messaging/SettingsJSHelper.kt | 76 ------------------- .../impl/RealPrivacyConfigDownloader.kt | 1 - 3 files changed, 124 deletions(-) delete mode 100644 app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsContentScopeJsMessageHandler.kt delete mode 100644 app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsJSHelper.kt diff --git a/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsContentScopeJsMessageHandler.kt b/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsContentScopeJsMessageHandler.kt deleted file mode 100644 index d225d94a843b..000000000000 --- a/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsContentScopeJsMessageHandler.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.app.settings.messaging - -import com.duckduckgo.app.settings.messaging.RealSettingsJSHelper.Companion.METHOD_OPEN_NATIVE_SETTINGS -import com.duckduckgo.app.settings.messaging.RealSettingsJSHelper.Companion.SERP_SETTINGS_FEATURE_NAME -import com.duckduckgo.common.utils.AppUrl -import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin -import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.js.messaging.api.JsMessage -import com.duckduckgo.js.messaging.api.JsMessageCallback -import com.duckduckgo.js.messaging.api.JsMessageHandler -import com.duckduckgo.js.messaging.api.JsMessaging -import com.squareup.anvil.annotations.ContributesMultibinding -import javax.inject.Inject - -@ContributesMultibinding(AppScope::class) -class SettingsContentScopeJsMessageHandler @Inject constructor() : ContentScopeJsMessageHandlersPlugin { - override fun getJsMessageHandler(): JsMessageHandler = object : JsMessageHandler { - override fun process(jsMessage: JsMessage, jsMessaging: JsMessaging, jsMessageCallback: JsMessageCallback?) { - jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id ?: "", jsMessage.params) - } - - override val allowedDomains: List = listOf( - AppUrl.Url.HOST, - ) - - override val featureName: String = SERP_SETTINGS_FEATURE_NAME - override val methods: List = listOf( - METHOD_OPEN_NATIVE_SETTINGS, - ) - } -} diff --git a/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsJSHelper.kt b/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsJSHelper.kt deleted file mode 100644 index 4f4b98b06e14..000000000000 --- a/app/src/main/java/com/duckduckgo/app/settings/messaging/SettingsJSHelper.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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.app.settings.messaging - -import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.js.messaging.api.JsCallbackData -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject -import logcat.LogPriority -import logcat.logcat -import org.json.JSONObject - -interface SettingsJSHelper { - suspend fun processJsCallbackMessage( - featureName: String, - method: String, - id: String?, - data: JSONObject?, - ): JsCallbackData? -} - -@ContributesBinding(AppScope::class) -class RealSettingsJSHelper @Inject constructor() : SettingsJSHelper { - - override suspend fun processJsCallbackMessage( - featureName: String, - method: String, - id: String?, - data: JSONObject?, - ): JsCallbackData? = when (method) { - METHOD_OPEN_NATIVE_SETTINGS -> { - val returnTo = data?.optString(PARAM_RETURN) - openNativeSettings(returnTo) - null - } - else -> null - } - - private fun openNativeSettings( - returnTo: String?, - ) { - when (returnTo) { - ID_AI_FEATURES_SETTINGS -> { - logcat { "Return: $returnTo" } - } - ID_PRIVATE_SEARCH_SETTINGS -> { - logcat { "Return: $returnTo" } - } - else -> { - logcat(LogPriority.WARN) { "Unknown settings return value: $returnTo" } - } - } - } - - companion object { - const val SERP_SETTINGS_FEATURE_NAME = "serpSettings" - const val METHOD_OPEN_NATIVE_SETTINGS = "openNativeSettings" - private const val ID_AI_FEATURES_SETTINGS = "aiFeatures" - private const val ID_PRIVATE_SEARCH_SETTINGS = "privateSearch" - private const val PARAM_RETURN = "return" - } -} diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt index ed92c167b1f3..22ebe888922a 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt @@ -60,7 +60,6 @@ class RealPrivacyConfigDownloader @Inject constructor( override suspend fun download(): PrivacyConfigDownloader.ConfigDownloadResult { logcat { "Downloading privacy config" } - return Error(null) // Default to error state, only return success at the end of the method when everything has worked val response = runCatching { privacyConfigService.privacyConfig() From e7780b62d772992168fb3ed9ac6beafe0c81f467 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 14:26:24 +0200 Subject: [PATCH 04/29] Fix duck.ai settings unit tests --- .../settings/DuckChatSettingsViewModelTest.kt | 63 +++++++++---------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt index ee57b0e7e124..beba8d3bc0c4 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt @@ -27,9 +27,9 @@ import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Comman import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenLinkInNewTab import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenShortcutSettings import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory -import com.duckduckgo.feature.toggles.api.Toggle.State -import com.duckduckgo.settings.api.SettingsPageFeature import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle +import com.duckduckgo.settings.api.SettingsPageFeature +import com.duckduckgo.feature.toggles.api.Toggle.State import junit.framework.TestCase.assertEquals import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -56,16 +56,14 @@ class DuckChatSettingsViewModelTest { private val settingsPageFeature = FakeFeatureToggleFactory.create(SettingsPageFeature::class.java) @Before - fun setUp() = - runTest { - @Suppress("DenyListedApi") - settingsPageFeature.saveAndExitSerpSettings().setRawStoredState(State(enable = false)) - whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) - whenever(duckChat.observeShowInBrowserMenuUserSetting()).thenReturn(flowOf(false)) - whenever(duckChat.observeShowInAddressBarUserSetting()).thenReturn(flowOf(false)) - whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(false)) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) - } + fun setUp() = runTest { + settingsPageFeature.saveAndExitSerpSettings().setRawStoredState(State(enable = false)) + whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) + whenever(duckChat.observeShowInBrowserMenuUserSetting()).thenReturn(flowOf(false)) + whenever(duckChat.observeShowInAddressBarUserSetting()).thenReturn(flowOf(false)) + whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(false)) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + } @Test fun whenDuckChatUserEnabledToggledDisabledThenSetUserSetting() = @@ -138,10 +136,9 @@ class DuckChatSettingsViewModelTest { } @Test - fun `input screen - user preference enabled then set correct state`() = - runTest { - whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(true)) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun `input screen - user preference enabled then set correct state`() = runTest { + whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(true)) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { assertTrue(awaitItem().isInputScreenEnabled) @@ -149,10 +146,9 @@ class DuckChatSettingsViewModelTest { } @Test - fun `input screen - user preference disabled then set correct state`() = - runTest { - whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(false)) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun `input screen - user preference disabled then set correct state`() = runTest { + whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(false)) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { assertFalse(awaitItem().isInputScreenEnabled) @@ -160,11 +156,10 @@ class DuckChatSettingsViewModelTest { } @Test - fun `input screen - when duck chat enabled and flag enabled, then emit enabled`() = - runTest { - whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) - whenever(duckChat.isInputScreenFeatureAvailable()).thenReturn(true) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun `input screen - when duck chat enabled and flag enabled, then emit enabled`() = runTest { + whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) + whenever(duckChat.isInputScreenFeatureAvailable()).thenReturn(true) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { val state = awaitItem() @@ -173,11 +168,10 @@ class DuckChatSettingsViewModelTest { } @Test - fun `input screen - when flag disabled, then emit disabled`() = - runTest { - whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) - whenever(duckChat.isInputScreenFeatureAvailable()).thenReturn(false) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun `input screen - when flag disabled, then emit disabled`() = runTest { + whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) + whenever(duckChat.isInputScreenFeatureAvailable()).thenReturn(false) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { val state = awaitItem() @@ -186,11 +180,10 @@ class DuckChatSettingsViewModelTest { } @Test - fun whenDuckChatDisabledThenNoSubTogglesShown() = - runTest { - whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(false)) - whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(true)) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun whenDuckChatDisabledThenNoSubTogglesShown() = runTest { + whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(false)) + whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(true)) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { val state = awaitItem() From d3c0ee98d826e2cc8b9cb950a6c89d686d89135c Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 15:19:42 +0200 Subject: [PATCH 05/29] Fix a false warning --- .../duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt index beba8d3bc0c4..014c67132365 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt @@ -57,6 +57,7 @@ class DuckChatSettingsViewModelTest { @Before fun setUp() = runTest { + @Suppress("DenyListedApi") settingsPageFeature.saveAndExitSerpSettings().setRawStoredState(State(enable = false)) whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) whenever(duckChat.observeShowInBrowserMenuUserSetting()).thenReturn(flowOf(false)) From 931e396c98e5103377e4b343dfe247ff355c7803 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 15:31:57 +0200 Subject: [PATCH 06/29] Fix spotless issues --- .../privatesearch/PrivateSearchActivity.kt | 11 ++-- .../ui/settings/DuckChatSettingsViewModel.kt | 11 ++-- .../settings/DuckChatSettingsViewModelTest.kt | 64 ++++++++++--------- 3 files changed, 47 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt index 0ad37cf1da57..c23c952ed2e3 100644 --- a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt @@ -112,11 +112,12 @@ class PrivateSearchActivity : DuckDuckGoActivity() { } private fun launchCustomizeSearchWebPage() { - val settingsUrl = if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { - DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM - } else { - DUCKDUCKGO_SETTINGS_WEB_LINK - } + val settingsUrl = + if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM + } else { + DUCKDUCKGO_SETTINGS_WEB_LINK + } globalActivityStarter.start( this, WebViewActivityWithParams( diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt index ea9548f8ecc8..7b9cda37650e 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt @@ -126,11 +126,12 @@ class DuckChatSettingsViewModel @Inject constructor( fun duckChatSearchAISettingsClicked() { viewModelScope.launch { - val settingsLink = if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM - } else { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK - } + val settingsLink = + if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM + } else { + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK + } commandChannel.send(OpenLinkInNewTab(settingsLink)) pixel.fire(DuckChatPixelName.DUCK_CHAT_SEARCH_ASSIST_SETTINGS_BUTTON_CLICKED) } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt index 014c67132365..ee57b0e7e124 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt @@ -27,9 +27,9 @@ import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Comman import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenLinkInNewTab import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenShortcutSettings import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory -import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle -import com.duckduckgo.settings.api.SettingsPageFeature import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.settings.api.SettingsPageFeature +import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle import junit.framework.TestCase.assertEquals import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -56,15 +56,16 @@ class DuckChatSettingsViewModelTest { private val settingsPageFeature = FakeFeatureToggleFactory.create(SettingsPageFeature::class.java) @Before - fun setUp() = runTest { - @Suppress("DenyListedApi") - settingsPageFeature.saveAndExitSerpSettings().setRawStoredState(State(enable = false)) - whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) - whenever(duckChat.observeShowInBrowserMenuUserSetting()).thenReturn(flowOf(false)) - whenever(duckChat.observeShowInAddressBarUserSetting()).thenReturn(flowOf(false)) - whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(false)) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) - } + fun setUp() = + runTest { + @Suppress("DenyListedApi") + settingsPageFeature.saveAndExitSerpSettings().setRawStoredState(State(enable = false)) + whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) + whenever(duckChat.observeShowInBrowserMenuUserSetting()).thenReturn(flowOf(false)) + whenever(duckChat.observeShowInAddressBarUserSetting()).thenReturn(flowOf(false)) + whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(false)) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + } @Test fun whenDuckChatUserEnabledToggledDisabledThenSetUserSetting() = @@ -137,9 +138,10 @@ class DuckChatSettingsViewModelTest { } @Test - fun `input screen - user preference enabled then set correct state`() = runTest { - whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(true)) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun `input screen - user preference enabled then set correct state`() = + runTest { + whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(true)) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { assertTrue(awaitItem().isInputScreenEnabled) @@ -147,9 +149,10 @@ class DuckChatSettingsViewModelTest { } @Test - fun `input screen - user preference disabled then set correct state`() = runTest { - whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(false)) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun `input screen - user preference disabled then set correct state`() = + runTest { + whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(false)) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { assertFalse(awaitItem().isInputScreenEnabled) @@ -157,10 +160,11 @@ class DuckChatSettingsViewModelTest { } @Test - fun `input screen - when duck chat enabled and flag enabled, then emit enabled`() = runTest { - whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) - whenever(duckChat.isInputScreenFeatureAvailable()).thenReturn(true) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun `input screen - when duck chat enabled and flag enabled, then emit enabled`() = + runTest { + whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) + whenever(duckChat.isInputScreenFeatureAvailable()).thenReturn(true) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { val state = awaitItem() @@ -169,10 +173,11 @@ class DuckChatSettingsViewModelTest { } @Test - fun `input screen - when flag disabled, then emit disabled`() = runTest { - whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) - whenever(duckChat.isInputScreenFeatureAvailable()).thenReturn(false) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun `input screen - when flag disabled, then emit disabled`() = + runTest { + whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(true)) + whenever(duckChat.isInputScreenFeatureAvailable()).thenReturn(false) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { val state = awaitItem() @@ -181,10 +186,11 @@ class DuckChatSettingsViewModelTest { } @Test - fun whenDuckChatDisabledThenNoSubTogglesShown() = runTest { - whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(false)) - whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(true)) - testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) + fun whenDuckChatDisabledThenNoSubTogglesShown() = + runTest { + whenever(duckChat.observeEnableDuckChatUserSetting()).thenReturn(flowOf(false)) + whenever(duckChat.observeInputScreenUserSettingEnabled()).thenReturn(flowOf(true)) + testee = DuckChatSettingsViewModel(duckChat, mockPixel, mockRebrandingFeatureToggle, mockInputScreenDiscoveryFunnel, settingsPageFeature) testee.viewState.test { val state = awaitItem() From 4e7722ff3e377cbd76f0ab4efaa093fd6ad3a11b Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 10:16:35 +0200 Subject: [PATCH 07/29] Add CSS dependencies to the settings-impl module --- settings/settings-impl/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/settings/settings-impl/build.gradle b/settings/settings-impl/build.gradle index 732fe32f156d..183063d95b1f 100644 --- a/settings/settings-impl/build.gradle +++ b/settings/settings-impl/build.gradle @@ -37,6 +37,8 @@ dependencies { implementation project(path: ':di') implementation project(path: ':settings-api') implementation project(path: ':common-utils') + implementation project(path: ':js-messaging-api') + implementation project(path: ':content-scope-scripts-api') implementation AndroidX.core.ktx From 7e5506a55f37dbf1d79719eb80c90f440d390486 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 10:32:30 +0200 Subject: [PATCH 08/29] Update the duck.ai settings link handling --- .../ui/settings/DuckChatSettingsActivity.kt | 2 +- .../ui/settings/DuckChatSettingsViewModel.kt | 30 ++++++++----------- .../src/main/res/values/donottranslate.xml | 19 ++++++++++++ 3 files changed, 33 insertions(+), 18 deletions(-) create mode 100644 duckchat/duckchat-impl/src/main/res/values/donottranslate.xml diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt index 827d4496db6c..62ed3a84ee3f 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt @@ -203,7 +203,7 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { this, WebViewActivityWithParams( url = command.link, - screenTitle = getString(R.string.duck_chat_title), + screenTitle = getString(command.titleRes), ), ) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt index 7b9cda37650e..35a85b1d400c 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt @@ -16,17 +16,20 @@ package com.duckduckgo.duckchat.impl.ui.settings +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.duckchat.impl.DuckChatInternal +import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.discovery.InputScreenDiscoveryFunnel import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenLink import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenLinkInNewTab import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenShortcutSettings +import com.duckduckgo.settings.api.SettingsConstants import com.duckduckgo.settings.api.SettingsPageFeature import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST @@ -72,14 +75,8 @@ class DuckChatSettingsViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ViewState()) sealed class Command { - data class OpenLink( - val link: String, - ) : Command() - - data class OpenLinkInNewTab( - val link: String, - ) : Command() - + data class OpenLink(val link: String, @StringRes val titleRes: Int) : Command() + data class OpenLinkInNewTab(val link: String) : Command() data object OpenShortcutSettings : Command() data object LaunchFeedback : Command() @@ -120,19 +117,18 @@ class DuckChatSettingsViewModel @Inject constructor( fun duckChatLearnMoreClicked() { viewModelScope.launch { - commandChannel.send(OpenLink(DUCK_CHAT_LEARN_MORE_LINK)) + commandChannel.send(OpenLink(DUCK_CHAT_LEARN_MORE_LINK, R.string.duck_chat_title)) } } fun duckChatSearchAISettingsClicked() { viewModelScope.launch { - val settingsLink = - if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM - } else { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK - } - commandChannel.send(OpenLinkInNewTab(settingsLink)) + val settingsLink = if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM + } else { + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK + } + commandChannel.send(OpenLink(settingsLink, R.string.duck_chat_search_assist_settings_title)) pixel.fire(DuckChatPixelName.DUCK_CHAT_SEARCH_ASSIST_SETTINGS_BUTTON_CLICKED) } } @@ -168,6 +164,6 @@ class DuckChatSettingsViewModel @Inject constructor( companion object { const val DUCK_CHAT_LEARN_MORE_LINK = "https://duckduckgo.com/duckduckgo-help-pages/aichat/" const val DUCK_CHAT_SEARCH_AI_SETTINGS_LINK = "https://duckduckgo.com/settings?ko=-1#aifeatures" - const val DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?ko=-1&return=aiFeatures#aifeatures" + const val DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?ko=-1&return=${SettingsConstants.ID_AI_FEATURES}#aifeatures" } } diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml new file mode 100644 index 000000000000..973308e87be6 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -0,0 +1,19 @@ + + + + Search Assist Settings + \ No newline at end of file From dd5ae82f79376a78ba6545507e6f183bd9ce3c02 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 10:32:54 +0200 Subject: [PATCH 09/29] Add settings CSS handler and constants --- .../settings/api/SettingsConstants.kt | 25 ++++++++++ .../SettingsContentScopeJsMessageHandler.kt | 46 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt create mode 100644 settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt diff --git a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt new file mode 100644 index 000000000000..7d340ea8c847 --- /dev/null +++ b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt @@ -0,0 +1,25 @@ +/* + * 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.settings.api + +object SettingsConstants { + const val FEATURE_SERP_SETTINGS = "serpSettings" + const val METHOD_OPEN_NATIVE_SETTINGS = "openNativeSettings" + const val PARAM_RETURN = "return" + const val ID_AI_FEATURES = "aiFeatures" + const val ID_PRIVATE_SEARCH = "privateSearch" +} diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt new file mode 100644 index 000000000000..3c9ed3019b95 --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt @@ -0,0 +1,46 @@ +/* + * 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.settings.impl.messaging + +import com.duckduckgo.common.utils.AppUrl +import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessageCallback +import com.duckduckgo.js.messaging.api.JsMessageHandler +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.settings.api.SettingsConstants +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class SettingsContentScopeJsMessageHandler @Inject constructor() : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = object : JsMessageHandler { + override fun process(jsMessage: JsMessage, jsMessaging: JsMessaging, jsMessageCallback: JsMessageCallback?) { + jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id ?: "", jsMessage.params) + } + + override val allowedDomains: List = listOf( + AppUrl.Url.HOST, + ) + + override val featureName: String = SettingsConstants.FEATURE_SERP_SETTINGS + override val methods: List = listOf( + SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + ) + } +} From 967c7e76338b1dd174cb6a41047c31d14aa4454b Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 10:33:27 +0200 Subject: [PATCH 10/29] Add a VM to WebViewActivity --- .../app/browser/webview/WebViewActivity.kt | 61 ++++++++++++- .../app/browser/webview/WebViewViewModel.kt | 87 +++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt index d80aeb8b1aef..27d8ff673024 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt @@ -23,6 +23,9 @@ import android.view.MenuItem import android.webkit.WebChromeClient import android.webkit.WebSettings import android.webkit.WebView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.BrowserActivity @@ -33,9 +36,15 @@ import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.js.messaging.api.JsMessageCallback +import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.navigation.api.getActivityParams import com.duckduckgo.user.agent.api.UserAgentProvider +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.json.JSONObject import javax.inject.Inject +import javax.inject.Named @InjectWith(ActivityScope::class) @ContributeToActivityStarter(WebViewActivityWithParams::class) @@ -50,6 +59,12 @@ class WebViewActivity : DuckDuckGoActivity() { @Inject lateinit var pixel: Pixel + private val viewModel: WebViewViewModel by bindViewModel() + + @Inject + @Named("ContentScopeScripts") + lateinit var contentScopeScripts: JsMessaging + private val binding: ActivityWebviewBinding by viewBinding() private val toolbar @@ -62,11 +77,41 @@ class WebViewActivity : DuckDuckGoActivity() { setContentView(binding.root) setupToolbar(toolbar) + val (url, supportNewWindows) = extractParameters() + + setupWebView(supportNewWindows) + setupCollectors() + + viewModel.onStart(url) + } + + private fun setupCollectors() { + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun processCommand(command: WebViewViewModel.Command) { + when (command) { + is WebViewViewModel.Command.LoadUrl -> { + binding.simpleWebview.loadUrl(command.url) + } + WebViewViewModel.Command.Exit -> { + finish() + } + } + } + + private fun extractParameters(): Pair { val params = intent.getActivityParams(WebViewActivityWithParams::class.java) val url = params?.url title = params?.screenTitle.orEmpty() val supportNewWindows = params?.supportNewWindows ?: false + return Pair(url, supportNewWindows) + } + private fun setupWebView(supportNewWindows: Boolean) { binding.simpleWebview.let { it.webViewClient = webViewClient @@ -102,10 +147,20 @@ class WebViewActivity : DuckDuckGoActivity() { databaseEnabled = false setSupportZoom(true) } - } - url?.let { - binding.simpleWebview.loadUrl(it) + contentScopeScripts.register( + it, + object : JsMessageCallback() { + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + viewModel.processJsCallbackMessage(featureName, method, id, data) + } + }, + ) } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt new file mode 100644 index 000000000000..113552f4418d --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt @@ -0,0 +1,87 @@ +/* + * 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.app.browser.webview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.settings.api.SettingsConstants +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import logcat.LogPriority +import logcat.logcat +import org.json.JSONObject +import javax.inject.Inject + +@ContributesViewModel(ActivityScope::class) +class WebViewViewModel @Inject constructor( +) : ViewModel() { + + private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) + val commands = commandChannel.receiveAsFlow() + + sealed class Command { + data class LoadUrl(val url: String) : Command() + data object Exit : Command() + } + + fun onStart(url: String?) { + if (url != null) { + sendCommand(Command.LoadUrl(url)) + } else { + logcat(LogPriority.ERROR) { "No URL provided to WebViewActivity" } + sendCommand(Command.Exit) + } + } + + fun processJsCallbackMessage( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + when (featureName) { + SettingsConstants.FEATURE_SERP_SETTINGS -> when (method) { + SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS -> { + val returnParam = data?.optString(SettingsConstants.PARAM_RETURN) + openNativeSettings(returnParam) + } + } + } + } + + private fun openNativeSettings(returnParam: String?) { + when (returnParam) { + SettingsConstants.ID_AI_FEATURES, + SettingsConstants.ID_PRIVATE_SEARCH -> { + sendCommand(Command.Exit) + } + else -> { + logcat(LogPriority.WARN) { "Unknown settings return value: $returnParam" } + } + } + } + + private fun sendCommand(command: Command) { + viewModelScope.launch { + commandChannel.send(command) + } + } +} From 8ff98d6ff734371d3fa30bd5a4be94fbc3f37cc1 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 16:10:39 +0200 Subject: [PATCH 11/29] Fix spotless warnings --- .../app/browser/webview/WebViewActivity.kt | 30 ++--- .../app/browser/webview/WebViewViewModel.kt | 23 ++-- .../ui/settings/DuckChatSettingsActivity.kt | 109 ++++++++++-------- .../ui/settings/DuckChatSettingsViewModel.kt | 26 +++-- .../SettingsContentScopeJsMessageHandler.kt | 31 +++-- 5 files changed, 128 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt index 27d8ff673024..5849382b65b5 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt @@ -49,7 +49,6 @@ import javax.inject.Named @InjectWith(ActivityScope::class) @ContributeToActivityStarter(WebViewActivityWithParams::class) class WebViewActivity : DuckDuckGoActivity() { - @Inject lateinit var userAgentProvider: UserAgentProvider @@ -116,22 +115,23 @@ class WebViewActivity : DuckDuckGoActivity() { it.webViewClient = webViewClient if (supportNewWindows) { - it.webChromeClient = object : WebChromeClient() { - override fun onCreateWindow( - view: WebView?, - isDialog: Boolean, - isUserGesture: Boolean, - resultMsg: Message?, - ): Boolean { - view?.requestFocusNodeHref(resultMsg) - val newWindowUrl = resultMsg?.data?.getString("url") - if (newWindowUrl != null) { - startActivity(BrowserActivity.intent(this@WebViewActivity, newWindowUrl)) - return true + it.webChromeClient = + object : WebChromeClient() { + override fun onCreateWindow( + view: WebView?, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message?, + ): Boolean { + view?.requestFocusNodeHref(resultMsg) + val newWindowUrl = resultMsg?.data?.getString("url") + if (newWindowUrl != null) { + startActivity(BrowserActivity.intent(this@WebViewActivity, newWindowUrl)) + return true + } + return false } - return false } - } } it.settings.apply { diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt index 113552f4418d..502c3ad4603a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt @@ -31,14 +31,15 @@ import org.json.JSONObject import javax.inject.Inject @ContributesViewModel(ActivityScope::class) -class WebViewViewModel @Inject constructor( -) : ViewModel() { - +class WebViewViewModel @Inject constructor() : ViewModel() { private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) val commands = commandChannel.receiveAsFlow() sealed class Command { - data class LoadUrl(val url: String) : Command() + data class LoadUrl( + val url: String, + ) : Command() + data object Exit : Command() } @@ -58,19 +59,21 @@ class WebViewViewModel @Inject constructor( data: JSONObject?, ) { when (featureName) { - SettingsConstants.FEATURE_SERP_SETTINGS -> when (method) { - SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS -> { - val returnParam = data?.optString(SettingsConstants.PARAM_RETURN) - openNativeSettings(returnParam) + SettingsConstants.FEATURE_SERP_SETTINGS -> + when (method) { + SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS -> { + val returnParam = data?.optString(SettingsConstants.PARAM_RETURN) + openNativeSettings(returnParam) + } } - } } } private fun openNativeSettings(returnParam: String?) { when (returnParam) { SettingsConstants.ID_AI_FEATURES, - SettingsConstants.ID_PRIVATE_SEARCH -> { + SettingsConstants.ID_PRIVATE_SEARCH, + -> { sendCommand(Command.Exit) } else -> { diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt index 62ed3a84ee3f..a29bcd3084d9 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt @@ -57,7 +57,6 @@ import com.duckduckgo.mobile.android.R as CommonR @InjectWith(ActivityScope::class) @ContributeToActivityStarter(DuckChatSettingsNoParams::class, screenName = "duckai.settings") class DuckChatSettingsActivity : DuckDuckGoActivity() { - private val viewModel: DuckChatSettingsViewModel by bindViewModel() private val binding: ActivityDuckChatSettingsBinding by viewBinding() @@ -119,15 +118,17 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { binding.showDuckChatSearchSettingsLink.setSecondaryText(getString(R.string.duck_chat_assist_settings_description_rebranding)) // align content with the main Duck.ai toggle's text - val offset = resources.getDimensionPixelSize(CommonR.dimen.listItemImageContainerSize) + - resources.getDimensionPixelSize(CommonR.dimen.keyline_4) + val offset = + resources.getDimensionPixelSize(CommonR.dimen.listItemImageContainerSize) + + resources.getDimensionPixelSize(CommonR.dimen.keyline_4) val orientation = resources.configuration.orientation binding.duckAiInputScreenToggleContainer.updatePadding( - left = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - 0 - } else { - offset - }, + left = + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + 0 + } else { + offset + }, ) binding.duckAiInputScreenDescription.updatePadding(left = offset) binding.duckAiShortcuts.updatePadding(left = offset) @@ -148,18 +149,21 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { } binding.duckChatSettingsText.addClickableSpan( - textSequence = if (viewState.isRebrandingAiFeaturesEnabled) { - getText(R.string.duck_chat_settings_activity_description_rebranding) - } else { - getText(R.string.duck_chat_settings_activity_description) - }, - spans = listOf( - "learn_more_link" to object : DuckDuckGoClickableSpan() { - override fun onClick(widget: View) { - viewModel.duckChatLearnMoreClicked() - } + textSequence = + if (viewState.isRebrandingAiFeaturesEnabled) { + getText(R.string.duck_chat_settings_activity_description_rebranding) + } else { + getText(R.string.duck_chat_settings_activity_description) }, - ), + spans = + listOf( + "learn_more_link" to + object : DuckDuckGoClickableSpan() { + override fun onClick(widget: View) { + viewModel.duckChatLearnMoreClicked() + } + }, + ), ) binding.duckAiInputScreenToggleContainer.isVisible = viewState.shouldShowInputScreenToggle @@ -171,13 +175,15 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { binding.duckAiInputScreenDescription.isVisible = viewState.shouldShowInputScreenToggle binding.duckAiInputScreenDescription.addClickableSpan( textSequence = getText(R.string.input_screen_user_pref_description), - spans = listOf( - "share_feedback" to object : DuckDuckGoClickableSpan() { - override fun onClick(widget: View) { - viewModel.duckAiInputScreenShareFeedbackClicked() - } - }, - ), + spans = + listOf( + "share_feedback" to + object : DuckDuckGoClickableSpan() { + override fun onClick(widget: View) { + viewModel.duckAiInputScreenShareFeedbackClicked() + } + }, + ), ) binding.duckAiShortcuts.isVisible = viewState.shouldShowShortcuts @@ -234,31 +240,42 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { duckAiInputScreenToggleWithAiCheck.setImageDrawable(ContextCompat.getDrawable(context, withAi.checkRes)) } - private sealed class InputScreenToggleButton(isActive: Boolean) { + private sealed class InputScreenToggleButton( + isActive: Boolean, + ) { abstract val imageRes: Int - val checkRes: Int = if (isActive) { - CommonR.drawable.ic_check_accent_24 - } else { - CommonR.drawable.ic_shape_circle_disabled_24 - } - - class WithoutAi(isActive: Boolean, isLightMode: Boolean) : InputScreenToggleButton(isActive) { - override val imageRes: Int = when { - isActive && isLightMode -> R.drawable.searchbox_withoutai_active - isActive && !isLightMode -> R.drawable.searchbox_withoutai_active_dark - !isActive && isLightMode -> R.drawable.searchbox_withoutai_inactive - else -> R.drawable.searchbox_withoutai_inactive_dark + val checkRes: Int = + if (isActive) { + CommonR.drawable.ic_check_accent_24 + } else { + CommonR.drawable.ic_shape_circle_disabled_24 } + + class WithoutAi( + isActive: Boolean, + isLightMode: Boolean, + ) : InputScreenToggleButton(isActive) { + override val imageRes: Int = + when { + isActive && isLightMode -> R.drawable.searchbox_withoutai_active + isActive && !isLightMode -> R.drawable.searchbox_withoutai_active_dark + !isActive && isLightMode -> R.drawable.searchbox_withoutai_inactive + else -> R.drawable.searchbox_withoutai_inactive_dark + } } - class WithAi(isActive: Boolean, isLightMode: Boolean) : InputScreenToggleButton(isActive) { - override val imageRes: Int = when { - isActive && isLightMode -> R.drawable.searchbox_withai_active - isActive && !isLightMode -> R.drawable.searchbox_withai_active_dark - !isActive && isLightMode -> R.drawable.searchbox_withai_inactive - else -> R.drawable.searchbox_withai_inactive_dark - } + class WithAi( + isActive: Boolean, + isLightMode: Boolean, + ) : InputScreenToggleButton(isActive) { + override val imageRes: Int = + when { + isActive && isLightMode -> R.drawable.searchbox_withai_active + isActive && !isLightMode -> R.drawable.searchbox_withai_active_dark + !isActive && isLightMode -> R.drawable.searchbox_withai_inactive + else -> R.drawable.searchbox_withai_inactive_dark + } } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt index 35a85b1d400c..a01e3b5014fc 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt @@ -75,8 +75,15 @@ class DuckChatSettingsViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ViewState()) sealed class Command { - data class OpenLink(val link: String, @StringRes val titleRes: Int) : Command() - data class OpenLinkInNewTab(val link: String) : Command() + data class OpenLink( + val link: String, + @StringRes val titleRes: Int, + ) : Command() + + data class OpenLinkInNewTab( + val link: String, + ) : Command() + data object OpenShortcutSettings : Command() data object LaunchFeedback : Command() @@ -123,11 +130,12 @@ class DuckChatSettingsViewModel @Inject constructor( fun duckChatSearchAISettingsClicked() { viewModelScope.launch { - val settingsLink = if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM - } else { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK - } + val settingsLink = + if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM + } else { + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK + } commandChannel.send(OpenLink(settingsLink, R.string.duck_chat_search_assist_settings_title)) pixel.fire(DuckChatPixelName.DUCK_CHAT_SEARCH_ASSIST_SETTINGS_BUTTON_CLICKED) } @@ -164,6 +172,8 @@ class DuckChatSettingsViewModel @Inject constructor( companion object { const val DUCK_CHAT_LEARN_MORE_LINK = "https://duckduckgo.com/duckduckgo-help-pages/aichat/" const val DUCK_CHAT_SEARCH_AI_SETTINGS_LINK = "https://duckduckgo.com/settings?ko=-1#aifeatures" - const val DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?ko=-1&return=${SettingsConstants.ID_AI_FEATURES}#aifeatures" + const val DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM = + "https://duckduckgo.com/settings?ko=-1&return=" + + "${SettingsConstants.ID_AI_FEATURES}#aifeatures" } } diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt index 3c9ed3019b95..617cee708425 100644 --- a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt @@ -29,18 +29,25 @@ import javax.inject.Inject @ContributesMultibinding(AppScope::class) class SettingsContentScopeJsMessageHandler @Inject constructor() : ContentScopeJsMessageHandlersPlugin { - override fun getJsMessageHandler(): JsMessageHandler = object : JsMessageHandler { - override fun process(jsMessage: JsMessage, jsMessaging: JsMessaging, jsMessageCallback: JsMessageCallback?) { - jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id ?: "", jsMessage.params) - } + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id ?: "", jsMessage.params) + } - override val allowedDomains: List = listOf( - AppUrl.Url.HOST, - ) + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + ) - override val featureName: String = SettingsConstants.FEATURE_SERP_SETTINGS - override val methods: List = listOf( - SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, - ) - } + override val featureName: String = SettingsConstants.FEATURE_SERP_SETTINGS + override val methods: List = + listOf( + SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + ) + } } From 08786478f88b078c076e68439ac52bc20c2223a9 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 17:00:44 +0200 Subject: [PATCH 12/29] Run the CSS handler on the IO dispatcher --- .../app/browser/webview/WebViewViewModel.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt index 502c3ad4603a..6b5880ab9149 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.browser.webview import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.settings.api.SettingsConstants import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST @@ -31,7 +32,9 @@ import org.json.JSONObject import javax.inject.Inject @ContributesViewModel(ActivityScope::class) -class WebViewViewModel @Inject constructor() : ViewModel() { +class WebViewViewModel @Inject constructor( + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) val commands = commandChannel.receiveAsFlow() @@ -58,14 +61,16 @@ class WebViewViewModel @Inject constructor() : ViewModel() { id: String?, data: JSONObject?, ) { - when (featureName) { - SettingsConstants.FEATURE_SERP_SETTINGS -> - when (method) { - SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS -> { - val returnParam = data?.optString(SettingsConstants.PARAM_RETURN) - openNativeSettings(returnParam) + viewModelScope.launch(dispatcherProvider.io()) { + when (featureName) { + SettingsConstants.FEATURE_SERP_SETTINGS -> + when (method) { + SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS -> { + val returnParam = data?.optString(SettingsConstants.PARAM_RETURN) + openNativeSettings(returnParam) + } } - } + } } } From 9d0ff68ab7d34d1f0825fbba9c39af6419f2f76a Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 17:03:12 +0200 Subject: [PATCH 13/29] Fix the back press deprecation and destroy the WebView on exit --- .../app/browser/webview/WebViewActivity.kt | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt index 5849382b65b5..70a38ff9290b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt @@ -23,6 +23,7 @@ import android.view.MenuItem import android.webkit.WebChromeClient import android.webkit.WebSettings import android.webkit.WebView +import androidx.activity.OnBackPressedCallback import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope @@ -43,6 +44,7 @@ import com.duckduckgo.user.agent.api.UserAgentProvider import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.json.JSONObject +import java.lang.System.exit import javax.inject.Inject import javax.inject.Named @@ -80,10 +82,26 @@ class WebViewActivity : DuckDuckGoActivity() { setupWebView(supportNewWindows) setupCollectors() + setupBackPressedDispatcher() viewModel.onStart(url) } + private fun setupBackPressedDispatcher() { + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.simpleWebview.canGoBack()) { + binding.simpleWebview.goBack() + } else { + exit() + } + } + }, + ) + } + private fun setupCollectors() { viewModel.commands .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) @@ -93,15 +111,20 @@ class WebViewActivity : DuckDuckGoActivity() { private fun processCommand(command: WebViewViewModel.Command) { when (command) { - is WebViewViewModel.Command.LoadUrl -> { - binding.simpleWebview.loadUrl(command.url) - } - WebViewViewModel.Command.Exit -> { - finish() - } + is WebViewViewModel.Command.LoadUrl -> binding.simpleWebview.loadUrl(command.url) + WebViewViewModel.Command.Exit -> exit() } } + private fun exit() { + binding.simpleWebview.stopLoading() + binding.simpleWebview.removeJavascriptInterface(contentScopeScripts.context) + binding.root.removeView(binding.simpleWebview) + binding.simpleWebview.destroy() + + finish() + } + private fun extractParameters(): Pair { val params = intent.getActivityParams(WebViewActivityWithParams::class.java) val url = params?.url @@ -167,18 +190,10 @@ class WebViewActivity : DuckDuckGoActivity() { override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { android.R.id.home -> { - super.onBackPressed() + onBackPressedDispatcher.onBackPressed() return true } } return super.onOptionsItemSelected(item) } - - override fun onBackPressed() { - if (binding.simpleWebview.canGoBack()) { - binding.simpleWebview.goBack() - } else { - super.onBackPressed() - } - } } From fe4495fdaf112b24f19e8713807bf3fb33ca4ef0 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 18:27:23 +0200 Subject: [PATCH 14/29] Reformat code --- .../app/browser/webview/WebViewActivity.kt | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt index 70a38ff9290b..2c7fecde0242 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt @@ -44,7 +44,6 @@ import com.duckduckgo.user.agent.api.UserAgentProvider import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.json.JSONObject -import java.lang.System.exit import javax.inject.Inject import javax.inject.Named @@ -133,8 +132,23 @@ class WebViewActivity : DuckDuckGoActivity() { return Pair(url, supportNewWindows) } + @SuppressLint("SetJavaScriptEnabled") private fun setupWebView(supportNewWindows: Boolean) { binding.simpleWebview.let { + contentScopeScripts.register( + it, + object : JsMessageCallback() { + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + viewModel.processJsCallbackMessage(featureName, method, id, data) + } + }, + ) + it.webViewClient = webViewClient if (supportNewWindows) { @@ -170,20 +184,6 @@ class WebViewActivity : DuckDuckGoActivity() { databaseEnabled = false setSupportZoom(true) } - - contentScopeScripts.register( - it, - object : JsMessageCallback() { - override fun process( - featureName: String, - method: String, - id: String?, - data: JSONObject?, - ) { - viewModel.processJsCallbackMessage(featureName, method, id, data) - } - }, - ) } } From 207caa68eb90afea1644677f5df4588e4c91fd08 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 26 Sep 2025 19:16:20 +0200 Subject: [PATCH 15/29] Fix formatting --- .../ui/settings/DuckChatSettingsViewModel.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt index a01e3b5014fc..b39613be4df1 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt @@ -130,13 +130,16 @@ class DuckChatSettingsViewModel @Inject constructor( fun duckChatSearchAISettingsClicked() { viewModelScope.launch { - val settingsLink = - if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM - } else { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK - } - commandChannel.send(OpenLink(settingsLink, R.string.duck_chat_search_assist_settings_title)) + if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + commandChannel.send( + OpenLink( + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM, + R.string.duck_chat_search_assist_settings_title, + ), + ) + } else { + commandChannel.send(OpenLinkInNewTab(DUCK_CHAT_SEARCH_AI_SETTINGS_LINK)) + } pixel.fire(DuckChatPixelName.DUCK_CHAT_SEARCH_ASSIST_SETTINGS_BUTTON_CLICKED) } } From 999f1b7d663d0f059d86374c00c4143ab6394920 Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 30 Sep 2025 17:57:47 +0200 Subject: [PATCH 16/29] Create a custom webview activity for settings --- .../privatesearch/PrivateSearchActivity.kt | 3 +- .../ui/settings/DuckChatSettingsActivity.kt | 3 +- .../api/SettingsWebViewScreenWithParams.kt | 24 +++ settings/settings-impl/build.gradle | 26 ++- .../src/main/AndroidManifest.xml | 29 +++ .../settings/impl/SettingsWebViewActivity.kt | 169 ++++++++++++++++++ .../settings/impl/SettingsWebViewClient.kt | 25 +++ .../settings/impl/SettingsWebViewViewModel.kt | 95 ++++++++++ .../res/layout/activity_settings_webview.xml | 35 ++++ 9 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsWebViewScreenWithParams.kt create mode 100644 settings/settings-impl/src/main/AndroidManifest.xml create mode 100644 settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt create mode 100644 settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewClient.kt create mode 100644 settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt create mode 100644 settings/settings-impl/src/main/res/layout/activity_settings_webview.xml diff --git a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt index c23c952ed2e3..7ef264d66f51 100644 --- a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt @@ -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 @@ -120,7 +121,7 @@ class PrivateSearchActivity : DuckDuckGoActivity() { } globalActivityStarter.start( this, - WebViewActivityWithParams( + SettingsWebViewScreenWithParams( url = settingsUrl, getString(R.string.privateSearchMoreSearchSettingsTitle), ), diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt index a29bcd3084d9..d4581fd261cf 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt @@ -49,6 +49,7 @@ import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.discovery.InputScreen import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_SETTINGS_DISPLAYED import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.ViewState import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.settings.api.SettingsWebViewScreenWithParams import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import javax.inject.Inject @@ -207,7 +208,7 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { is DuckChatSettingsViewModel.Command.OpenLink -> { globalActivityStarter.start( this, - WebViewActivityWithParams( + SettingsWebViewScreenWithParams( url = command.link, screenTitle = getString(command.titleRes), ), diff --git a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsWebViewScreenWithParams.kt b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsWebViewScreenWithParams.kt new file mode 100644 index 000000000000..dda87bd1e166 --- /dev/null +++ b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsWebViewScreenWithParams.kt @@ -0,0 +1,24 @@ +/* + * 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.settings.api + +import com.duckduckgo.navigation.api.GlobalActivityStarter + +data class SettingsWebViewScreenWithParams( + val url: String, + val screenTitle: String, +) : GlobalActivityStarter.ActivityParams diff --git a/settings/settings-impl/build.gradle b/settings/settings-impl/build.gradle index 183063d95b1f..60bfb94ef22a 100644 --- a/settings/settings-impl/build.gradle +++ b/settings/settings-impl/build.gradle @@ -32,15 +32,25 @@ android { dependencies { anvil project(path: ':anvil-compiler') - implementation project(path: ':anvil-annotations') - - implementation project(path: ':di') - implementation project(path: ':settings-api') - implementation project(path: ':common-utils') - implementation project(path: ':js-messaging-api') - implementation project(path: ':content-scope-scripts-api') - + implementation project(':anvil-annotations') + + implementation project(':di') + implementation project(':browser-api') + implementation project(':design-system') + implementation project(':navigation-api') + implementation project(':user-agent-api') + implementation project(':settings-api') + implementation project(':common-utils') + implementation project(':js-messaging-api') + implementation project(':statistics-api') + implementation project(':content-scope-scripts-api') + + implementation "com.squareup.logcat:logcat:_" + + implementation AndroidX.appCompat implementation AndroidX.core.ktx + implementation AndroidX.webkit + implementation KotlinX.coroutines.android // Dagger implementation Google.dagger diff --git a/settings/settings-impl/src/main/AndroidManifest.xml b/settings/settings-impl/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..6132911b63d1 --- /dev/null +++ b/settings/settings-impl/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt new file mode 100644 index 000000000000..ca00c1b7e2f5 --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2021 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.settings.impl + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.MenuItem +import android.webkit.WebSettings +import androidx.activity.OnBackPressedCallback +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.js.messaging.api.JsMessageCallback +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.navigation.api.getActivityParams +import com.duckduckgo.settings.api.SettingsWebViewScreenWithParams +import com.duckduckgo.settings.impl.databinding.ActivitySettingsWebviewBinding +import com.duckduckgo.user.agent.api.UserAgentProvider +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Named +import kotlin.jvm.java + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(SettingsWebViewScreenWithParams::class) +class SettingsWebViewActivity : DuckDuckGoActivity() { + @Inject + lateinit var userAgentProvider: UserAgentProvider + + @Inject + lateinit var webViewClient: SettingsWebViewClient + + @Inject + lateinit var pixel: Pixel + + private val viewModel: SettingsWebViewViewModel by bindViewModel() + + @Inject + @Named("ContentScopeScripts") + lateinit var contentScopeScripts: JsMessaging + + private val binding: ActivitySettingsWebviewBinding by viewBinding() + + private val toolbar + get() = binding.includeToolbar.toolbar + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val params = intent.getActivityParams(SettingsWebViewScreenWithParams::class.java) + val url = params?.url + title = params?.screenTitle.orEmpty() + + setContentView(binding.root) + setupToolbar(toolbar) + + setupWebView() + setupCollectors() + setupBackPressedDispatcher() + + viewModel.onStart(url) + } + + private fun setupBackPressedDispatcher() { + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.settingsWebView.canGoBack()) { + binding.settingsWebView.goBack() + } else { + exit() + } + } + }, + ) + } + + private fun setupCollectors() { + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun processCommand(command: SettingsWebViewViewModel.Command) { + when (command) { + is SettingsWebViewViewModel.Command.LoadUrl -> binding.settingsWebView.loadUrl(command.url) + SettingsWebViewViewModel.Command.Exit -> exit() + } + } + + private fun exit() { + binding.settingsWebView.stopLoading() + binding.settingsWebView.removeJavascriptInterface(contentScopeScripts.context) + binding.root.removeView(binding.settingsWebView) + binding.settingsWebView.destroy() + + finish() + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView() { + binding.settingsWebView.let { + contentScopeScripts.register( + it, + object : JsMessageCallback() { + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + viewModel.processJsCallbackMessage(featureName, method, id, data) + } + }, + ) + + it.webViewClient = webViewClient + + it.settings.apply { + userAgentString = userAgentProvider.userAgent() + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + databaseEnabled = false + setSupportZoom(true) + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } +} diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewClient.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewClient.kt new file mode 100644 index 000000000000..11802fa0a501 --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewClient.kt @@ -0,0 +1,25 @@ +/* + * 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.settings.impl + +import android.webkit.WebViewClient +import javax.inject.Inject + +/** + * Custom implementation of [WebViewClient] specific to Settings + */ +class SettingsWebViewClient @Inject constructor() : WebViewClient() diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt new file mode 100644 index 000000000000..6657c19a1c19 --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt @@ -0,0 +1,95 @@ +/* + * 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.settings.impl + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.settings.api.SettingsConstants +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import logcat.LogPriority +import logcat.logcat +import org.json.JSONObject +import javax.inject.Inject + +@ContributesViewModel(ActivityScope::class) +class SettingsWebViewViewModel @Inject constructor( + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) + val commands = commandChannel.receiveAsFlow() + + sealed class Command { + data class LoadUrl( + val url: String, + ) : Command() + + data object Exit : Command() + } + + fun onStart(url: String?) { + if (url != null) { + sendCommand(Command.LoadUrl(url)) + } else { + logcat(LogPriority.ERROR) { "No URL provided to WebViewActivity" } + sendCommand(Command.Exit) + } + } + + fun processJsCallbackMessage( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + viewModelScope.launch(dispatcherProvider.io()) { + when (featureName) { + SettingsConstants.FEATURE_SERP_SETTINGS -> + when (method) { + SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS -> { + val returnParam = data?.optString(SettingsConstants.PARAM_RETURN) + openNativeSettings(returnParam) + } + } + } + } + } + + private fun openNativeSettings(returnParam: String?) { + when (returnParam) { + SettingsConstants.ID_AI_FEATURES, + SettingsConstants.ID_PRIVATE_SEARCH, + -> { + sendCommand(Command.Exit) + } + else -> { + logcat(LogPriority.WARN) { "Unknown settings return value: $returnParam" } + } + } + } + + private fun sendCommand(command: Command) { + viewModelScope.launch { + commandChannel.send(command) + } + } +} diff --git a/settings/settings-impl/src/main/res/layout/activity_settings_webview.xml b/settings/settings-impl/src/main/res/layout/activity_settings_webview.xml new file mode 100644 index 000000000000..08774b20b30d --- /dev/null +++ b/settings/settings-impl/src/main/res/layout/activity_settings_webview.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + From 41cd7433f0e134dee8307d98b70e508b7ed0dd60 Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 30 Sep 2025 18:08:23 +0200 Subject: [PATCH 17/29] Revert WebViewActivity changes --- .../app/browser/webview/WebViewActivity.kt | 126 ++++-------------- .../app/browser/webview/WebViewViewModel.kt | 95 ------------- 2 files changed, 28 insertions(+), 193 deletions(-) delete mode 100644 app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt index 2c7fecde0242..d80aeb8b1aef 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt @@ -23,10 +23,6 @@ import android.view.MenuItem import android.webkit.WebChromeClient import android.webkit.WebSettings import android.webkit.WebView -import androidx.activity.OnBackPressedCallback -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.BrowserActivity @@ -37,19 +33,14 @@ import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.js.messaging.api.JsMessageCallback -import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.navigation.api.getActivityParams import com.duckduckgo.user.agent.api.UserAgentProvider -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.json.JSONObject import javax.inject.Inject -import javax.inject.Named @InjectWith(ActivityScope::class) @ContributeToActivityStarter(WebViewActivityWithParams::class) class WebViewActivity : DuckDuckGoActivity() { + @Inject lateinit var userAgentProvider: UserAgentProvider @@ -59,12 +50,6 @@ class WebViewActivity : DuckDuckGoActivity() { @Inject lateinit var pixel: Pixel - private val viewModel: WebViewViewModel by bindViewModel() - - @Inject - @Named("ContentScopeScripts") - lateinit var contentScopeScripts: JsMessaging - private val binding: ActivityWebviewBinding by viewBinding() private val toolbar @@ -77,98 +62,31 @@ class WebViewActivity : DuckDuckGoActivity() { setContentView(binding.root) setupToolbar(toolbar) - val (url, supportNewWindows) = extractParameters() - - setupWebView(supportNewWindows) - setupCollectors() - setupBackPressedDispatcher() - - viewModel.onStart(url) - } - - private fun setupBackPressedDispatcher() { - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (binding.simpleWebview.canGoBack()) { - binding.simpleWebview.goBack() - } else { - exit() - } - } - }, - ) - } - - private fun setupCollectors() { - viewModel.commands - .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) - .onEach { processCommand(it) } - .launchIn(lifecycleScope) - } - - private fun processCommand(command: WebViewViewModel.Command) { - when (command) { - is WebViewViewModel.Command.LoadUrl -> binding.simpleWebview.loadUrl(command.url) - WebViewViewModel.Command.Exit -> exit() - } - } - - private fun exit() { - binding.simpleWebview.stopLoading() - binding.simpleWebview.removeJavascriptInterface(contentScopeScripts.context) - binding.root.removeView(binding.simpleWebview) - binding.simpleWebview.destroy() - - finish() - } - - private fun extractParameters(): Pair { val params = intent.getActivityParams(WebViewActivityWithParams::class.java) val url = params?.url title = params?.screenTitle.orEmpty() val supportNewWindows = params?.supportNewWindows ?: false - return Pair(url, supportNewWindows) - } - @SuppressLint("SetJavaScriptEnabled") - private fun setupWebView(supportNewWindows: Boolean) { binding.simpleWebview.let { - contentScopeScripts.register( - it, - object : JsMessageCallback() { - override fun process( - featureName: String, - method: String, - id: String?, - data: JSONObject?, - ) { - viewModel.processJsCallbackMessage(featureName, method, id, data) - } - }, - ) - it.webViewClient = webViewClient if (supportNewWindows) { - it.webChromeClient = - object : WebChromeClient() { - override fun onCreateWindow( - view: WebView?, - isDialog: Boolean, - isUserGesture: Boolean, - resultMsg: Message?, - ): Boolean { - view?.requestFocusNodeHref(resultMsg) - val newWindowUrl = resultMsg?.data?.getString("url") - if (newWindowUrl != null) { - startActivity(BrowserActivity.intent(this@WebViewActivity, newWindowUrl)) - return true - } - return false + it.webChromeClient = object : WebChromeClient() { + override fun onCreateWindow( + view: WebView?, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message?, + ): Boolean { + view?.requestFocusNodeHref(resultMsg) + val newWindowUrl = resultMsg?.data?.getString("url") + if (newWindowUrl != null) { + startActivity(BrowserActivity.intent(this@WebViewActivity, newWindowUrl)) + return true } + return false } + } } it.settings.apply { @@ -185,15 +103,27 @@ class WebViewActivity : DuckDuckGoActivity() { setSupportZoom(true) } } + + url?.let { + binding.simpleWebview.loadUrl(it) + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() + super.onBackPressed() return true } } return super.onOptionsItemSelected(item) } + + override fun onBackPressed() { + if (binding.simpleWebview.canGoBack()) { + binding.simpleWebview.goBack() + } else { + super.onBackPressed() + } + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt deleted file mode 100644 index 6b5880ab9149..000000000000 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewViewModel.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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.app.browser.webview - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.duckduckgo.anvil.annotations.ContributesViewModel -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.settings.api.SettingsConstants -import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import logcat.LogPriority -import logcat.logcat -import org.json.JSONObject -import javax.inject.Inject - -@ContributesViewModel(ActivityScope::class) -class WebViewViewModel @Inject constructor( - private val dispatcherProvider: DispatcherProvider, -) : ViewModel() { - private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) - val commands = commandChannel.receiveAsFlow() - - sealed class Command { - data class LoadUrl( - val url: String, - ) : Command() - - data object Exit : Command() - } - - fun onStart(url: String?) { - if (url != null) { - sendCommand(Command.LoadUrl(url)) - } else { - logcat(LogPriority.ERROR) { "No URL provided to WebViewActivity" } - sendCommand(Command.Exit) - } - } - - fun processJsCallbackMessage( - featureName: String, - method: String, - id: String?, - data: JSONObject?, - ) { - viewModelScope.launch(dispatcherProvider.io()) { - when (featureName) { - SettingsConstants.FEATURE_SERP_SETTINGS -> - when (method) { - SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS -> { - val returnParam = data?.optString(SettingsConstants.PARAM_RETURN) - openNativeSettings(returnParam) - } - } - } - } - } - - private fun openNativeSettings(returnParam: String?) { - when (returnParam) { - SettingsConstants.ID_AI_FEATURES, - SettingsConstants.ID_PRIVATE_SEARCH, - -> { - sendCommand(Command.Exit) - } - else -> { - logcat(LogPriority.WARN) { "Unknown settings return value: $returnParam" } - } - } - } - - private fun sendCommand(command: Command) { - viewModelScope.launch { - commandChannel.send(command) - } - } -} From ab3ccc4af388d74c8de2716cf1312201f49c6507 Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 30 Sep 2025 22:54:29 +0200 Subject: [PATCH 18/29] Move the CoreContentScopeScript to the API module --- .../api/CoreContentScopeScripts.kt | 32 +++ .../ContentScopeScriptsJsInjectorPlugin.kt | 7 +- .../impl/RealContentScopeScripts.kt | 14 +- .../ContentScopeScriptsJsMessaging.kt | 59 +++--- ...entScopeScriptsPostMessageWrapperPlugin.kt | 2 +- ...ContentScopeScriptsJsInjectorPluginTest.kt | 4 +- .../ContentScopeScriptsJsMessagingTest.kt | 198 ++++++++++-------- ...copeScriptsPostMessageWrapperPluginTest.kt | 2 +- 8 files changed, 187 insertions(+), 131 deletions(-) create mode 100644 content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/CoreContentScopeScripts.kt diff --git a/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/CoreContentScopeScripts.kt b/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/CoreContentScopeScripts.kt new file mode 100644 index 000000000000..f7adc86aca63 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/CoreContentScopeScripts.kt @@ -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, + ): String + + fun isEnabled(): Boolean + + val secret: String + val javascriptInterface: String + val callbackName: String +} 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..dd9e08bf2288 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 @@ -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 @@ -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 } } 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 8ce059892dd4..2cae8af04628 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 @@ -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 @@ -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, - ): String - - fun isEnabled(): Boolean - - val secret: String - val javascriptInterface: String - val callbackName: String -} - @SingleInstanceIn(AppScope::class) @ContributesBinding(AppScope::class) class RealContentScopeScripts @Inject constructor( diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt index 726e7e64030e..32a600558e16 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt @@ -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 @@ -49,7 +49,6 @@ class ContentScopeScriptsJsMessaging @Inject constructor( private val coreContentScopeScripts: CoreContentScopeScripts, private val handlers: PluginPoint, ) : JsMessaging { - private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() private lateinit var webView: WebView @@ -61,13 +60,17 @@ class ContentScopeScriptsJsMessaging @Inject constructor( override val allowedDomains: List = 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") { @@ -79,10 +82,13 @@ 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) { @@ -90,7 +96,10 @@ class ContentScopeScriptsJsMessaging @Inject constructor( } } - 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 @@ -98,25 +107,27 @@ class ContentScopeScriptsJsMessaging @Inject constructor( } 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) } } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPlugin.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPlugin.kt index ce68e8f62fc5..b6c1f60a6bab 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPlugin.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPlugin.kt @@ -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 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 7454005b868a..01a81d3faa2d 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,7 +1,7 @@ package com.duckduckgo.contentscopescripts.impl import android.webkit.WebView -import org.junit.Assert.* +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -10,10 +10,8 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever -import java.util.* class ContentScopeScriptsJsInjectorPluginTest { - private val mockCoreContentScopeScripts: CoreContentScopeScripts = mock() private val mockWebView: WebView = mock() diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt index 4351a4e3534a..80405e46fabf 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt @@ -21,7 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.test.CoroutineTestRule 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.js.messaging.api.JsMessage import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessageHandler @@ -51,13 +51,11 @@ class ContentScopeScriptsJsMessagingTest { private lateinit var contentScopeScriptsJsMessaging: ContentScopeScriptsJsMessaging private class FakePluginPoint : PluginPoint { - override fun getPlugins(): Collection { - return listOf(FakePlugin()) - } + override fun getPlugins(): Collection = listOf(FakePlugin()) inner class FakePlugin : ContentScopeJsMessageHandlersPlugin { - override fun getJsMessageHandler(): JsMessageHandler { - return object : JsMessageHandler { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { override fun process( jsMessage: JsMessage, jsMessaging: JsMessaging, @@ -70,7 +68,6 @@ class ContentScopeScriptsJsMessagingTest { override val featureName: String = "webCompat" override val methods: List = listOf("webShare", "permissionsQuery") } - } } } @@ -79,61 +76,69 @@ class ContentScopeScriptsJsMessagingTest { whenever(coreContentScopeScripts.secret).thenReturn("secret") whenever(coreContentScopeScripts.javascriptInterface).thenReturn("javascriptInterface") whenever(coreContentScopeScripts.callbackName).thenReturn("callbackName") - contentScopeScriptsJsMessaging = ContentScopeScriptsJsMessaging( - jsMessageHelper, - coroutineRule.testDispatcherProvider, - coreContentScopeScripts, - handlers, - ) + contentScopeScriptsJsMessaging = + ContentScopeScriptsJsMessaging( + jsMessageHelper, + coroutineRule.testDispatcherProvider, + coreContentScopeScripts, + handlers, + ) } @Test - fun `when process and message can be handled then execute callback`() = runTest { - givenInterfaceIsRegistered() + fun `when process and message can be handled then execute callback`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(1, callback.counter) - } + assertEquals(1, callback.counter) + } @Test - fun `when processing unknown message do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when processing unknown message do nothing`() = + runTest { + givenInterfaceIsRegistered() - contentScopeScriptsJsMessaging.process("", contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process("", contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `when processing unknown secret do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when processing unknown secret do nothing`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, "test") + contentScopeScriptsJsMessaging.process(message, "test") - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `if interface is not registered do nothing`() = runTest { - whenever(mockWebView.url).thenReturn("https://example.com") + fun `if interface is not registered do nothing`() = + runTest { + whenever(mockWebView.url).thenReturn("https://example.com") - val message = """ - {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test fun `when registering interface then add javascript interface is called`() { @@ -143,77 +148,94 @@ class ContentScopeScriptsJsMessagingTest { } @Test - fun `when url is not allowed do nothing`() = runTest { - givenInterfaceIsRegistered() - whenever(mockWebView.url).thenReturn("https://nowAllowed.com") + fun `when url is not allowed do nothing`() = + runTest { + givenInterfaceIsRegistered() + whenever(mockWebView.url).thenReturn("https://nowAllowed.com") - val message = """ - {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `when feature does not match do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when feature does not match do nothing`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"test","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"test","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `when id does not exist do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when id does not exist do nothing`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","webCompat":"test","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","webCompat":"test","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `when processing addDebugFlag message with valid secret and domain then process message`() = runTest { - givenInterfaceIsRegistered() + fun `when processing addDebugFlag message with valid secret and domain then process message`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"debugFeature","id":"debugId","method":"addDebugFlag","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"debugFeature","id":"debugId","method":"addDebugFlag","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(1, callback.counter) - } + assertEquals(1, callback.counter) + } @Test - fun `when processing addDebugFlag message with wrong secret then do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when processing addDebugFlag message with wrong secret then do nothing`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"debugFeature","id":"debugId","method":"addDebugFlag","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"debugFeature","id":"debugId","method":"addDebugFlag","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, "wrongSecret") + contentScopeScriptsJsMessaging.process(message, "wrongSecret") - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } - private val callback = object : JsMessageCallback() { - var counter = 0 - override fun process(featureName: String, method: String, id: String?, data: JSONObject?) { - counter++ + private val callback = + object : JsMessageCallback() { + var counter = 0 + + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + counter++ + } } - } private fun givenInterfaceIsRegistered() { contentScopeScriptsJsMessaging.register(mockWebView, callback) diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPluginTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPluginTest.kt index d6313ad32772..9cbf38cfffcf 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPluginTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPluginTest.kt @@ -1,7 +1,7 @@ package com.duckduckgo.contentscopescripts.impl.messaging 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.js.messaging.api.JsMessageHelper import com.duckduckgo.js.messaging.api.SubscriptionEvent From cd882f46733dceae746fea9af4eece10f1cec286 Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 30 Sep 2025 22:55:00 +0200 Subject: [PATCH 19/29] Use the WebViewCompatWrapper to inject CSS --- .../settings/impl/SettingsWebViewActivity.kt | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt index ca00c1b7e2f5..51ad73d5173d 100644 --- a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt @@ -27,9 +27,10 @@ import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams +import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging @@ -39,6 +40,7 @@ import com.duckduckgo.settings.impl.databinding.ActivitySettingsWebviewBinding import com.duckduckgo.user.agent.api.UserAgentProvider import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import org.json.JSONObject import javax.inject.Inject import javax.inject.Named @@ -53,11 +55,17 @@ class SettingsWebViewActivity : DuckDuckGoActivity() { @Inject lateinit var webViewClient: SettingsWebViewClient + @Inject + lateinit var webViewCompat: WebViewCompatWrapper + @Inject lateinit var pixel: Pixel private val viewModel: SettingsWebViewViewModel by bindViewModel() + @Inject + lateinit var css: CoreContentScopeScripts + @Inject @Named("ContentScopeScripts") lateinit var contentScopeScripts: JsMessaging @@ -78,11 +86,13 @@ class SettingsWebViewActivity : DuckDuckGoActivity() { setContentView(binding.root) setupToolbar(toolbar) - setupWebView() - setupCollectors() - setupBackPressedDispatcher() + lifecycleScope.launch { + setupWebView() + setupCollectors() + setupBackPressedDispatcher() - viewModel.onStart(url) + viewModel.onStart(url) + } } private fun setupBackPressedDispatcher() { @@ -124,7 +134,7 @@ class SettingsWebViewActivity : DuckDuckGoActivity() { } @SuppressLint("SetJavaScriptEnabled") - private fun setupWebView() { + private suspend fun setupWebView() { binding.settingsWebView.let { contentScopeScripts.register( it, @@ -140,6 +150,15 @@ class SettingsWebViewActivity : DuckDuckGoActivity() { }, ) + webViewCompat.addDocumentStartJavaScript( + it, + "javascript:${css.getScript(false, emptyList())}", + setOf( + "https://duckduckgo.com", // exact origin + "https://*.duckduckgo.com", // any subdomain + ), + ) + it.webViewClient = webViewClient it.settings.apply { From 644281de45f00aac0466f4ddfc92955ef4ff292a Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 30 Sep 2025 22:58:29 +0200 Subject: [PATCH 20/29] Fix spotless issues --- .../impl/RealContentScopeScripts.kt | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) 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 2cae8af04628..2dd70ba0f0df 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 @@ -49,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() - 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() - private var cachedUnprotectTemporaryExceptionsJson: String = emptyJsonList + private var cachedUnprotectTemporaryExceptionsJson: String = EMPTY_JSON_LIST private lateinit var cachedContentScopeJS: String @@ -102,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 { @@ -133,7 +132,7 @@ class RealContentScopeScripts @Inject constructor( private fun cacheUserUnprotectedDomains(userUnprotectedDomains: List) { cachedUserUnprotectedDomains.clear() if (userUnprotectedDomains.isEmpty()) { - cachedUserUnprotectedDomainsJson = emptyJsonList + cachedUserUnprotectedDomainsJson = EMPTY_JSON_LIST } else { cachedUserUnprotectedDomainsJson = getUserUnprotectedDomainsJson(userUnprotectedDomains) cachedUserUnprotectedDomains.addAll(userUnprotectedDomains) @@ -143,7 +142,7 @@ class RealContentScopeScripts @Inject constructor( private fun cacheUserUnprotectedTemporaryExceptions(unprotectedTemporaryExceptions: List) { cachedUnprotectTemporaryExceptions.clear() if (unprotectedTemporaryExceptions.isEmpty()) { - cachedUnprotectTemporaryExceptionsJson = emptyJsonList + cachedUnprotectTemporaryExceptionsJson = EMPTY_JSON_LIST } else { cachedUnprotectTemporaryExceptionsJson = getUnprotectedTemporaryJson(unprotectedTemporaryExceptions) cachedUnprotectTemporaryExceptions.addAll(unprotectedTemporaryExceptions) @@ -153,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 { @@ -180,8 +180,9 @@ class RealContentScopeScripts @Inject constructor( activeExperiments: List, ): 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}" } @@ -189,10 +190,15 @@ class RealContentScopeScripts @Inject constructor( } 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): String { return runBlocking { val type = Types.newParameterizedType(List::class.java, Experiment::class.java) @@ -212,21 +218,23 @@ class RealContentScopeScripts @Inject constructor( } } - private fun getContentScopeJson(config: String, unprotectedTemporaryExceptions: List): String = ( - "{\"features\":{$config},\"unprotectedTemporary\":${getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)}}" + 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("-", "") - } + 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("-", "") } } From 59e2e1d4bd70359e444eaa2b5f44b7938eead09d Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 1 Oct 2025 15:56:06 +0200 Subject: [PATCH 21/29] Fix the test build --- .../contentscopescripts/impl/RealContentScopeScriptsTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt index f677f044667c..64426a217ac1 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt @@ -22,6 +22,7 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.BuildFlavor import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.FeatureException import com.duckduckgo.feature.toggles.api.Toggle From 2d110c32d8be22dddc0d6b78609d47d6d950a462 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 1 Oct 2025 18:16:26 +0200 Subject: [PATCH 22/29] Fix ktlint issues --- .../privatesearch/PrivateSearchActivity.kt | 1 - .../impl/RealContentScopeScripts.kt | 2 +- .../ui/settings/DuckChatSettingsActivity.kt | 53 +++++++++---------- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt index 7ef264d66f51..0c4dc9a922c2 100644 --- a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt @@ -28,7 +28,6 @@ import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.ActivityPrivateSearchBinding import com.duckduckgo.app.privatesearch.PrivateSearchViewModel.Command import com.duckduckgo.browser.api.ui.BrowserScreens.PrivateSearchScreenNoParams -import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope 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 2dd70ba0f0df..b7f14390163f 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 @@ -224,7 +224,7 @@ class RealContentScopeScripts @Inject constructor( ): String = ( "{\"features\":{$config},\"unprotectedTemporary\":${getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)}}" - ) + ) companion object { const val EMPTY_JSON_LIST = "[]" diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt index d4581fd261cf..d716351281e0 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt @@ -33,7 +33,6 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.browser.api.ui.BrowserScreens.FeedbackActivityWithEmptyParams -import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.spans.DuckDuckGoClickableSpan import com.duckduckgo.common.ui.store.AppTheme @@ -125,11 +124,11 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { val orientation = resources.configuration.orientation binding.duckAiInputScreenToggleContainer.updatePadding( left = - if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - 0 - } else { - offset - }, + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + 0 + } else { + offset + }, ) binding.duckAiInputScreenDescription.updatePadding(left = offset) binding.duckAiShortcuts.updatePadding(left = offset) @@ -151,20 +150,20 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { binding.duckChatSettingsText.addClickableSpan( textSequence = - if (viewState.isRebrandingAiFeaturesEnabled) { - getText(R.string.duck_chat_settings_activity_description_rebranding) - } else { - getText(R.string.duck_chat_settings_activity_description) - }, + if (viewState.isRebrandingAiFeaturesEnabled) { + getText(R.string.duck_chat_settings_activity_description_rebranding) + } else { + getText(R.string.duck_chat_settings_activity_description) + }, spans = - listOf( - "learn_more_link" to - object : DuckDuckGoClickableSpan() { - override fun onClick(widget: View) { - viewModel.duckChatLearnMoreClicked() - } - }, - ), + listOf( + "learn_more_link" to + object : DuckDuckGoClickableSpan() { + override fun onClick(widget: View) { + viewModel.duckChatLearnMoreClicked() + } + }, + ), ) binding.duckAiInputScreenToggleContainer.isVisible = viewState.shouldShowInputScreenToggle @@ -177,14 +176,14 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { binding.duckAiInputScreenDescription.addClickableSpan( textSequence = getText(R.string.input_screen_user_pref_description), spans = - listOf( - "share_feedback" to - object : DuckDuckGoClickableSpan() { - override fun onClick(widget: View) { - viewModel.duckAiInputScreenShareFeedbackClicked() - } - }, - ), + listOf( + "share_feedback" to + object : DuckDuckGoClickableSpan() { + override fun onClick(widget: View) { + viewModel.duckAiInputScreenShareFeedbackClicked() + } + }, + ), ) binding.duckAiShortcuts.isVisible = viewState.shouldShowShortcuts From d16f1a49f292a213de12d860e0d13c92436037f4 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 3 Oct 2025 17:38:09 +0200 Subject: [PATCH 23/29] Guard the private search behind a feature flag --- .../privatesearch/PrivateSearchActivity.kt | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt index 0c4dc9a922c2..9ee4ad7f8d60 100644 --- a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt @@ -28,6 +28,7 @@ import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.ActivityPrivateSearchBinding import com.duckduckgo.app.privatesearch.PrivateSearchViewModel.Command import com.duckduckgo.browser.api.ui.BrowserScreens.PrivateSearchScreenNoParams +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope @@ -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, - SettingsWebViewScreenWithParams( - 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" } } From b4a6d3875c9fbf9684cf5397794cb08f93bd647c Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 3 Oct 2025 17:47:00 +0200 Subject: [PATCH 24/29] Put the duck chat link opening behind a feature flag --- .../ui/settings/DuckChatSettingsActivity.kt | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt index d716351281e0..6e8a5c302d0e 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt @@ -33,6 +33,7 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.browser.api.ui.BrowserScreens.FeedbackActivityWithEmptyParams +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.spans.DuckDuckGoClickableSpan import com.duckduckgo.common.ui.store.AppTheme @@ -48,6 +49,7 @@ import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.discovery.InputScreen import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_SETTINGS_DISPLAYED import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.ViewState 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 @@ -78,6 +80,9 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var appTheme: AppTheme + @Inject + lateinit var settingsPageFeature: SettingsPageFeature + @Inject lateinit var inputScreenDiscoveryFunnel: InputScreenDiscoveryFunnel @@ -205,13 +210,23 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { private fun processCommand(command: DuckChatSettingsViewModel.Command) { when (command) { is DuckChatSettingsViewModel.Command.OpenLink -> { - globalActivityStarter.start( - this, - SettingsWebViewScreenWithParams( - url = command.link, - screenTitle = getString(command.titleRes), - ), - ) + if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + globalActivityStarter.start( + this, + SettingsWebViewScreenWithParams( + url = command.link, + screenTitle = getString(command.titleRes), + ), + ) + } else { + globalActivityStarter.start( + this, + WebViewActivityWithParams( + url = command.link, + screenTitle = getString(R.string.duck_chat_title), + ), + ) + } } is DuckChatSettingsViewModel.Command.OpenLinkInNewTab -> { startActivity(browserNav.openInNewTab(this@DuckChatSettingsActivity, command.link)) From 7cbbf5bd8214307bf4e23674ae09b2fe7d2c6b85 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 3 Oct 2025 18:02:08 +0200 Subject: [PATCH 25/29] Move serpSettings constants to the impl module --- .../com/duckduckgo/settings/api/SettingsConstants.kt | 3 --- .../settings/impl/SettingsWebViewViewModel.kt | 9 ++++++--- .../messaging/SettingsContentScopeJsMessageHandler.kt | 11 ++++++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt index 7d340ea8c847..8ec9b7fc2869 100644 --- a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt +++ b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt @@ -17,9 +17,6 @@ package com.duckduckgo.settings.api object SettingsConstants { - const val FEATURE_SERP_SETTINGS = "serpSettings" - const val METHOD_OPEN_NATIVE_SETTINGS = "openNativeSettings" - const val PARAM_RETURN = "return" const val ID_AI_FEATURES = "aiFeatures" const val ID_PRIVATE_SEARCH = "privateSearch" } diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt index 6657c19a1c19..11771c4e8147 100644 --- a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt @@ -22,6 +22,9 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.settings.api.SettingsConstants +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.FEATURE_SERP_SETTINGS +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.METHOD_OPEN_NATIVE_SETTINGS +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.PARAM_RETURN import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow @@ -63,10 +66,10 @@ class SettingsWebViewViewModel @Inject constructor( ) { viewModelScope.launch(dispatcherProvider.io()) { when (featureName) { - SettingsConstants.FEATURE_SERP_SETTINGS -> + FEATURE_SERP_SETTINGS -> when (method) { - SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS -> { - val returnParam = data?.optString(SettingsConstants.PARAM_RETURN) + METHOD_OPEN_NATIVE_SETTINGS -> { + val returnParam = data?.optString(PARAM_RETURN) openNativeSettings(returnParam) } } diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt index 617cee708425..d501f901f550 100644 --- a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt @@ -23,7 +23,6 @@ import com.duckduckgo.js.messaging.api.JsMessage import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessageHandler import com.duckduckgo.js.messaging.api.JsMessaging -import com.duckduckgo.settings.api.SettingsConstants import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject @@ -44,10 +43,16 @@ class SettingsContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ AppUrl.Url.HOST, ) - override val featureName: String = SettingsConstants.FEATURE_SERP_SETTINGS + override val featureName: String = FEATURE_SERP_SETTINGS override val methods: List = listOf( - SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + METHOD_OPEN_NATIVE_SETTINGS, ) } + + companion object { + internal const val FEATURE_SERP_SETTINGS = "serpSettings" + internal const val METHOD_OPEN_NATIVE_SETTINGS = "openNativeSettings" + internal const val PARAM_RETURN = "return" + } } From 3f06e424d43b59d7b53c7cf84c4f3ee2826a2845 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 3 Oct 2025 18:09:52 +0200 Subject: [PATCH 26/29] Remove the redundant webviewclient --- .../settings/impl/SettingsWebViewActivity.kt | 6 ----- .../settings/impl/SettingsWebViewClient.kt | 25 ------------------- 2 files changed, 31 deletions(-) delete mode 100644 settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewClient.kt diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt index 51ad73d5173d..bab039a4a00d 100644 --- a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt @@ -44,7 +44,6 @@ import kotlinx.coroutines.launch import org.json.JSONObject import javax.inject.Inject import javax.inject.Named -import kotlin.jvm.java @InjectWith(ActivityScope::class) @ContributeToActivityStarter(SettingsWebViewScreenWithParams::class) @@ -52,9 +51,6 @@ class SettingsWebViewActivity : DuckDuckGoActivity() { @Inject lateinit var userAgentProvider: UserAgentProvider - @Inject - lateinit var webViewClient: SettingsWebViewClient - @Inject lateinit var webViewCompat: WebViewCompatWrapper @@ -159,8 +155,6 @@ class SettingsWebViewActivity : DuckDuckGoActivity() { ), ) - it.webViewClient = webViewClient - it.settings.apply { userAgentString = userAgentProvider.userAgent() javaScriptEnabled = true diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewClient.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewClient.kt deleted file mode 100644 index 11802fa0a501..000000000000 --- a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewClient.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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.settings.impl - -import android.webkit.WebViewClient -import javax.inject.Inject - -/** - * Custom implementation of [WebViewClient] specific to Settings - */ -class SettingsWebViewClient @Inject constructor() : WebViewClient() From 589314ab3150eedb179d8fdfb936bd0a6f96a1f3 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 1 Oct 2025 18:53:59 +0200 Subject: [PATCH 27/29] Add VM tests --- settings/settings-impl/build.gradle | 17 ++ .../impl/SettingsWebViewViewModelTest.kt | 148 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt diff --git a/settings/settings-impl/build.gradle b/settings/settings-impl/build.gradle index 60bfb94ef22a..ce3e56ae2b10 100644 --- a/settings/settings-impl/build.gradle +++ b/settings/settings-impl/build.gradle @@ -27,6 +27,11 @@ android { anvil { generateDaggerFactories = true // default is false } + testOptions { + unitTests { + includeAndroidResources = true + } + } } @@ -54,4 +59,16 @@ dependencies { // Dagger implementation Google.dagger + + testImplementation Testing.junit4 + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation project(path: ':common-test') + testImplementation CashApp.turbine + testImplementation(KotlinX.coroutines.test) { + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + testImplementation "androidx.test.ext:junit-ktx:_" + testImplementation AndroidX.lifecycle.runtime.testing + testImplementation AndroidX.archCore.testing + testImplementation Testing.robolectric } \ No newline at end of file diff --git a/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt b/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt new file mode 100644 index 000000000000..b3fb7504f470 --- /dev/null +++ b/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt @@ -0,0 +1,148 @@ +/* + * 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.settings.impl + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.settings.api.SettingsConstants +import com.duckduckgo.settings.impl.SettingsWebViewViewModel.Command +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SettingsWebViewViewModelTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private lateinit var viewModel: SettingsWebViewViewModel + + @Before + fun setup() { + viewModel = SettingsWebViewViewModel(coroutineTestRule.testDispatcherProvider) + } + + @Test + fun whenOnStartWithUrlThenLoadUrlCommandEmitted() = runTest { + val testUrl = "https://example.com/settings" + viewModel.commands.test { + viewModel.onStart(testUrl) + + val command = awaitItem() + assertTrue(command is Command.LoadUrl) + assertEquals(testUrl, (command as Command.LoadUrl).url) + } + } + + @Test + fun whenOnStartWithNullUrlThenExitCommandEmitted() = runTest { + viewModel.commands.test { + viewModel.onStart(null) + + val command = awaitItem() + assertTrue(command is Command.Exit) + } + } + + @Test + fun whenProcessOpenNativeSettingsAiFeaturesReturnThenExitCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(SettingsConstants.PARAM_RETURN, SettingsConstants.ID_AI_FEATURES) + viewModel.processJsCallbackMessage( + featureName = SettingsConstants.FEATURE_SERP_SETTINGS, + method = SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + id = null, + data = data, + ) + + val command = awaitItem() + assertTrue(command is Command.Exit) + } + } + + @Test + fun whenProcessOpenNativeSettingsPrivateSearchReturnThenExitCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(SettingsConstants.PARAM_RETURN, SettingsConstants.ID_PRIVATE_SEARCH) + viewModel.processJsCallbackMessage( + featureName = SettingsConstants.FEATURE_SERP_SETTINGS, + method = SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + id = null, + data = data, + ) + + val command = awaitItem() + assertTrue(command is Command.Exit) + } + } + + @Test + fun whenProcessOpenNativeSettingsUnknownReturnThenNoCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(SettingsConstants.PARAM_RETURN, "unknownSection") + viewModel.processJsCallbackMessage( + featureName = SettingsConstants.FEATURE_SERP_SETTINGS, + method = SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + id = null, + data = data, + ) + // Advance to run launched coroutines + advanceUntilIdle() + expectNoEvents() + } + } + + @Test + fun whenProcessDifferentFeatureNameThenNoCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(SettingsConstants.PARAM_RETURN, SettingsConstants.ID_AI_FEATURES) + viewModel.processJsCallbackMessage( + featureName = "otherFeature", + method = SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + id = null, + data = data, + ) + advanceUntilIdle() + expectNoEvents() + } + } + + @Test + fun whenProcessDifferentMethodThenNoCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(SettingsConstants.PARAM_RETURN, SettingsConstants.ID_AI_FEATURES) + viewModel.processJsCallbackMessage( + featureName = SettingsConstants.FEATURE_SERP_SETTINGS, + method = "someOtherMethod", + id = null, + data = data, + ) + advanceUntilIdle() + expectNoEvents() + } + } +} From f1d7e5ec7b36dcec5812bc790edc4be0c088175f Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 1 Oct 2025 19:09:05 +0200 Subject: [PATCH 28/29] Add a test for feature flag being enabled --- .../settings/DuckChatSettingsViewModelTest.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt index ee57b0e7e124..461c217e3c0c 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt @@ -20,6 +20,7 @@ import app.cash.turbine.test import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.impl.DuckChatInternal +import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.discovery.InputScreenDiscoveryFunnel import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.LaunchFeedback @@ -239,6 +240,27 @@ class DuckChatSettingsViewModelTest { verify(mockPixel).fire(DuckChatPixelName.DUCK_CHAT_SEARCH_ASSIST_SETTINGS_BUTTON_CLICKED) } + @Test + fun whenDuckChatSearchAISettingsClickedAndSaveAndExitEnabledThenOpenSettingsLinkWithReturnParamEmitted() = + runTest { + @Suppress("DenyListedApi") + settingsPageFeature.saveAndExitSerpSettings().setRawStoredState(State(enable = true)) + + testee.duckChatSearchAISettingsClicked() + + testee.commands.test { + val command = awaitItem() + assertTrue(command is OpenLink) + command as OpenLink + assertEquals( + DuckChatSettingsViewModel.DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM, + command.link, + ) + assertEquals(R.string.duck_chat_search_assist_settings_title, command.titleRes) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `when onDuckChatUserEnabledToggled true then enabled pixel fired`() = runTest { From 3b1afd721df761bda73711b58b94cd9abe82a48e Mon Sep 17 00:00:00 2001 From: 0nko Date: Mon, 6 Oct 2025 09:18:54 +0200 Subject: [PATCH 29/29] Fix unit tests --- .../impl/SettingsWebViewViewModelTest.kt | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt b/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt index b3fb7504f470..5f1e624e212b 100644 --- a/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt +++ b/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt @@ -19,8 +19,12 @@ package com.duckduckgo.settings.impl import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.settings.api.SettingsConstants +import com.duckduckgo.settings.api.SettingsConstants.ID_AI_FEATURES +import com.duckduckgo.settings.api.SettingsConstants.ID_PRIVATE_SEARCH import com.duckduckgo.settings.impl.SettingsWebViewViewModel.Command +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.FEATURE_SERP_SETTINGS +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.METHOD_OPEN_NATIVE_SETTINGS +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.PARAM_RETURN import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -71,10 +75,10 @@ class SettingsWebViewViewModelTest { @Test fun whenProcessOpenNativeSettingsAiFeaturesReturnThenExitCommandEmitted() = runTest { viewModel.commands.test { - val data = JSONObject().put(SettingsConstants.PARAM_RETURN, SettingsConstants.ID_AI_FEATURES) + val data = JSONObject().put(PARAM_RETURN, ID_AI_FEATURES) viewModel.processJsCallbackMessage( - featureName = SettingsConstants.FEATURE_SERP_SETTINGS, - method = SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + featureName = FEATURE_SERP_SETTINGS, + method = METHOD_OPEN_NATIVE_SETTINGS, id = null, data = data, ) @@ -87,10 +91,10 @@ class SettingsWebViewViewModelTest { @Test fun whenProcessOpenNativeSettingsPrivateSearchReturnThenExitCommandEmitted() = runTest { viewModel.commands.test { - val data = JSONObject().put(SettingsConstants.PARAM_RETURN, SettingsConstants.ID_PRIVATE_SEARCH) + val data = JSONObject().put(PARAM_RETURN, ID_PRIVATE_SEARCH) viewModel.processJsCallbackMessage( - featureName = SettingsConstants.FEATURE_SERP_SETTINGS, - method = SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + featureName = FEATURE_SERP_SETTINGS, + method = METHOD_OPEN_NATIVE_SETTINGS, id = null, data = data, ) @@ -103,10 +107,10 @@ class SettingsWebViewViewModelTest { @Test fun whenProcessOpenNativeSettingsUnknownReturnThenNoCommandEmitted() = runTest { viewModel.commands.test { - val data = JSONObject().put(SettingsConstants.PARAM_RETURN, "unknownSection") + val data = JSONObject().put(PARAM_RETURN, "unknownSection") viewModel.processJsCallbackMessage( - featureName = SettingsConstants.FEATURE_SERP_SETTINGS, - method = SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + featureName = FEATURE_SERP_SETTINGS, + method = METHOD_OPEN_NATIVE_SETTINGS, id = null, data = data, ) @@ -119,10 +123,10 @@ class SettingsWebViewViewModelTest { @Test fun whenProcessDifferentFeatureNameThenNoCommandEmitted() = runTest { viewModel.commands.test { - val data = JSONObject().put(SettingsConstants.PARAM_RETURN, SettingsConstants.ID_AI_FEATURES) + val data = JSONObject().put(PARAM_RETURN, ID_AI_FEATURES) viewModel.processJsCallbackMessage( featureName = "otherFeature", - method = SettingsConstants.METHOD_OPEN_NATIVE_SETTINGS, + method = METHOD_OPEN_NATIVE_SETTINGS, id = null, data = data, ) @@ -134,9 +138,9 @@ class SettingsWebViewViewModelTest { @Test fun whenProcessDifferentMethodThenNoCommandEmitted() = runTest { viewModel.commands.test { - val data = JSONObject().put(SettingsConstants.PARAM_RETURN, SettingsConstants.ID_AI_FEATURES) + val data = JSONObject().put(PARAM_RETURN, ID_AI_FEATURES) viewModel.processJsCallbackMessage( - featureName = SettingsConstants.FEATURE_SERP_SETTINGS, + featureName = FEATURE_SERP_SETTINGS, method = "someOtherMethod", id = null, data = data,