diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 885e1fcd120e..af5b77410b88 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -43,6 +43,7 @@ import androidx.lifecycle.Observer import androidx.room.Room import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import app.cash.turbine.test import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.ValueCaptorObserver import com.duckduckgo.app.accessibility.data.AccessibilitySettingsDataStore @@ -233,6 +234,7 @@ import com.duckduckgo.common.utils.baseHost import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider +import com.duckduckgo.contentscopescripts.api.ContentScopeScriptsSubscriptionEventPlugin import com.duckduckgo.downloads.api.DownloadStateListener import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload @@ -259,12 +261,8 @@ import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.history.api.HistoryEntry.VisitedPage import com.duckduckgo.history.api.NavigationHistory -import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScriptPlugin import com.duckduckgo.js.messaging.api.JsCallbackData -import com.duckduckgo.js.messaging.api.PostMessageWrapperPlugin import com.duckduckgo.js.messaging.api.SubscriptionEventData -import com.duckduckgo.js.messaging.api.WebMessagingPlugin -import com.duckduckgo.js.messaging.api.WebViewCompatMessageCallback import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels @@ -290,6 +288,7 @@ import com.duckduckgo.savedsites.api.models.SavedSite.Favorite import com.duckduckgo.savedsites.impl.SavedSitesPixelName import com.duckduckgo.serp.logos.api.SerpEasterEggLogosToggles import com.duckduckgo.serp.logos.api.SerpLogo +import com.duckduckgo.settings.api.SettingsPageFeature import com.duckduckgo.site.permissions.api.SitePermissionsManager import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse @@ -619,6 +618,9 @@ class BrowserTabViewModelTest { private val mockDeviceAppLookup: DeviceAppLookup = mock() + private lateinit var fakeContentScopeScriptsSubscriptionEventPluginPoint: FakeContentScopeScriptsSubscriptionEventPluginPoint + private var fakeSettingsPageFeature = FakeFeatureToggleFactory.create(SettingsPageFeature::class.java) + @Before fun before() = runTest { @@ -758,6 +760,8 @@ class BrowserTabViewModelTest { whenever(mockSiteErrorHandlerKillSwitch.self()).thenReturn(mockSiteErrorHandlerKillSwitchToggle) + fakeContentScopeScriptsSubscriptionEventPluginPoint = FakeContentScopeScriptsSubscriptionEventPluginPoint() + testee = BrowserTabViewModel( statisticsUpdater = mockStatisticsUpdater, @@ -849,6 +853,8 @@ class BrowserTabViewModelTest { addressBarTrackersAnimationFeatureToggle = mockAddressBarTrackersAnimationFeatureToggle, autoconsentPixelManager = mockAutoconsentPixelManager, omnibarFeatureRepository = mockOmnibarFeatureRepository, + contentScopeScriptsSubscriptionEventPluginPoint = fakeContentScopeScriptsSubscriptionEventPluginPoint, + settingsPageFeature = fakeSettingsPageFeature, ) testee.loadData("abc", null, false, false) @@ -7874,74 +7880,102 @@ class BrowserTabViewModelTest { override fun getCustomHeaders(url: String): Map = headers } - class FakeAddDocumentStartJavaScriptPlugin( - override val context: String, - ) : AddDocumentStartJavaScriptPlugin { - var countInitted = 0 - private set - - override suspend fun addDocumentStartJavaScript(webView: WebView) { - countInitted++ - } + class FakeContentScopeScriptsSubscriptionEventPlugin( + private val eventData: SubscriptionEventData, + ) : ContentScopeScriptsSubscriptionEventPlugin { + override fun getSubscriptionEventData(): SubscriptionEventData = eventData } - class FakeAddDocumentStartJavaScriptPluginPoint : PluginPoint { - val cssPlugin = FakeAddDocumentStartJavaScriptPlugin("contentScopeScripts") - val otherPlugin = FakeAddDocumentStartJavaScriptPlugin("test") + class FakeContentScopeScriptsSubscriptionEventPluginPoint : PluginPoint { - override fun getPlugins() = listOf(cssPlugin, otherPlugin) + private val plugins: MutableList = mutableListOf() + + fun addPlugins(plugins: List) { + this.plugins.addAll(plugins) + } + + override fun getPlugins(): Collection = plugins } - class FakeWebMessagingPlugin : WebMessagingPlugin { - var registered = false - private set + @Test + fun whenOnViewResumedWithNoPluginsThenNoSubscriptionEventsSent() = runTest { + fakeSettingsPageFeature.serpSettingsSync().setRawStoredState(State(enable = true)) - override suspend fun unregister(webView: WebView) { - registered = false - } + testee.onViewResumed() - override suspend fun register( - jsMessageCallback: WebViewCompatMessageCallback, - webView: WebView, - ) { - registered = true + testee.subscriptionEventDataFlow.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() } + } - override suspend fun postMessage( - webView: WebView, - subscriptionEventData: SubscriptionEventData, - ) { + @Test + fun whenOnViewResumedWithPluginsThenSubscriptionEventsSent() = runTest { + fakeSettingsPageFeature.serpSettingsSync().setRawStoredState(State(enable = true)) + val events = mutableListOf().apply { + add( + SubscriptionEventData( + featureName = "event1", + subscriptionName = "subscription1", + params = JSONObject().put("param1", "value1"), + ), + ) + add( + SubscriptionEventData( + featureName = "event2", + subscriptionName = "subscription2", + params = JSONObject().put("param2", "value2"), + ), + ) } - override val context: String - get() = "test" - } + fakeContentScopeScriptsSubscriptionEventPluginPoint.addPlugins( + events.map { FakeContentScopeScriptsSubscriptionEventPlugin(it) }, + ) - class FakeWebMessagingPluginPoint : PluginPoint { - val plugin = FakeWebMessagingPlugin() + testee.onViewResumed() - override fun getPlugins(): Collection = listOf(plugin) + testee.subscriptionEventDataFlow.test { + for (expectedEvent in events) { + val emittedEvent = awaitItem() + assertEquals(expectedEvent.featureName, emittedEvent.featureName) + assertEquals(expectedEvent.subscriptionName, emittedEvent.subscriptionName) + assertEquals(expectedEvent.params.toString(), emittedEvent.params.toString()) + } + cancelAndIgnoreRemainingEvents() + } } - class FakePostMessageWrapperPlugin : PostMessageWrapperPlugin { - var postMessageCalled = false - private set - - override suspend fun postMessage( - message: SubscriptionEventData, - webView: WebView, - ) { - postMessageCalled = true + @Test + fun whenOnViewResumedWithPluginsAndSerpSettingsFeatureFlagOffThenNoEventsSent() = runTest { + fakeSettingsPageFeature.serpSettingsSync().setRawStoredState(State(enable = false)) + val events = mutableListOf().apply { + add( + SubscriptionEventData( + featureName = "event1", + subscriptionName = "subscription1", + params = JSONObject().put("param1", "value1"), + ), + ) + add( + SubscriptionEventData( + featureName = "event2", + subscriptionName = "subscription2", + params = JSONObject().put("param2", "value2"), + ), + ) } - override val context: String - get() = "contentScopeScripts" - } + fakeContentScopeScriptsSubscriptionEventPluginPoint.addPlugins( + events.map { FakeContentScopeScriptsSubscriptionEventPlugin(it) }, + ) - class FakePostMessageWrapperPluginPoint : PluginPoint { - val plugin = FakePostMessageWrapperPlugin() + testee.onViewResumed() - override fun getPlugins(): Collection = listOf(plugin) + testee.subscriptionEventDataFlow.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } } @Test diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 2334a3436490..d7995296353e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -992,6 +992,15 @@ class BrowserTabFragment : pendingUploadTask = null } viewModel.handleExternalLaunch(isLaunchedFromExternalApp) + + observeSubscriptionEventDataChannel() + } + + private fun observeSubscriptionEventDataChannel() { + viewModel.subscriptionEventDataFlow.onEach { subscriptionEventData -> + logcat { "SERP-Settings: Sending subscription event data to content scope scripts: $subscriptionEventData" } + contentScopeScripts.sendSubscriptionEvent(subscriptionEventData) + }.launchIn(lifecycleScope) } private fun resumeWebView() { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 4dd2b99befaf..76d992e0f1ea 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -308,6 +308,7 @@ import com.duckduckgo.common.utils.isMobileSite import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider import com.duckduckgo.common.utils.toDesktopUri +import com.duckduckgo.contentscopescripts.api.ContentScopeScriptsSubscriptionEventPlugin import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.downloads.api.DownloadCommand import com.duckduckgo.downloads.api.DownloadStateListener @@ -323,6 +324,7 @@ import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.SubscriptionEventData import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING @@ -350,6 +352,7 @@ import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment.Delete import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment.EditSavedSiteListener import com.duckduckgo.serp.logos.api.SerpEasterEggLogosToggles import com.duckduckgo.serp.logos.api.SerpLogo +import com.duckduckgo.settings.api.SettingsPageFeature import com.duckduckgo.site.permissions.api.SitePermissionsManager import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse @@ -365,6 +368,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -383,6 +387,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -490,6 +495,8 @@ class BrowserTabViewModel @Inject constructor( private val addressBarTrackersAnimationFeatureToggle: AddressBarTrackersAnimationFeatureToggle, private val autoconsentPixelManager: AutoconsentPixelManager, private val omnibarFeatureRepository: OmnibarFeatureRepository, + private val contentScopeScriptsSubscriptionEventPluginPoint: PluginPoint, + private val settingsPageFeature: SettingsPageFeature, ) : ViewModel(), WebViewClientListener, EditSavedSiteListener, @@ -539,6 +546,9 @@ class BrowserTabViewModel @Inject constructor( private var activeExperiments: List? = null + private val _subscriptionEventDataChannel = Channel(capacity = Channel.BUFFERED) + val subscriptionEventDataFlow: Flow = _subscriptionEventDataChannel.receiveAsFlow() + data class HiddenBookmarksIds( val favorites: List = emptyList(), val bookmarks: List = emptyList(), @@ -941,6 +951,14 @@ class BrowserTabViewModel @Inject constructor( lastFullSiteUrlEnabled = settingsDataStore.isFullUrlEnabled command.value = Command.RefreshOmnibar } + + if (settingsPageFeature.serpSettingsSync().isEnabled()) { + viewModelScope.launch { + contentScopeScriptsSubscriptionEventPluginPoint.getPlugins().forEach { plugin -> + _subscriptionEventDataChannel.send(plugin.getSubscriptionEventData()) + } + } + } } fun onViewVisible() { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt index 63372ab1709c..eba0ec5ec4df 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -68,7 +68,6 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.SingleLiveEvent import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue import kotlinx.coroutines.CoroutineScope @@ -102,7 +101,6 @@ class BrowserViewModel @Inject constructor( private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, private val additionalDefaultBrowserPrompts: AdditionalDefaultBrowserPrompts, private val swipingTabsFeature: SwipingTabsFeatureProvider, - private val duckChat: DuckChat, ) : ViewModel(), CoroutineScope { override val coroutineContext: CoroutineContext diff --git a/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt index d33e25e105ef..ebe66bef44ea 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt @@ -42,7 +42,6 @@ import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.ui.tabs.SwipingTabsFeature import com.duckduckgo.common.ui.tabs.SwipingTabsFeatureProvider -import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State import kotlinx.coroutines.channels.Channel @@ -97,8 +96,6 @@ class BrowserViewModelTest { @Mock private lateinit var mockAdditionalDefaultBrowserPrompts: AdditionalDefaultBrowserPrompts - @Mock private lateinit var mockDuckChat: DuckChat - private val fakeShowOnAppLaunchFeatureToggle = FakeFeatureToggleFactory.create(ShowOnAppLaunchFeature::class.java) private lateinit var testee: BrowserViewModel @@ -535,7 +532,6 @@ class BrowserViewModelTest { showOnAppLaunchOptionHandler = showOnAppLaunchOptionHandler, additionalDefaultBrowserPrompts = mockAdditionalDefaultBrowserPrompts, swipingTabsFeature = swipingTabsFeatureProvider, - duckChat = mockDuckChat, ) } diff --git a/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/ContentScopeScriptsSubscriptionEventPlugin.kt b/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/ContentScopeScriptsSubscriptionEventPlugin.kt new file mode 100644 index 000000000000..4c53c1a92ddd --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/ContentScopeScriptsSubscriptionEventPlugin.kt @@ -0,0 +1,31 @@ +/* + * 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.js.messaging.api.SubscriptionEventData + +/** + * Use this interface to create a new plugin that will provide [SubscriptionEventData] that can be sent to C-S-S + */ +interface ContentScopeScriptsSubscriptionEventPlugin { + + /** + * This method returns a [SubscriptionEventData] that can be sent to C-S-S + * @return [SubscriptionEventData] + */ + fun getSubscriptionEventData(): SubscriptionEventData +} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsSubscriptionEventPluginPoint.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsSubscriptionEventPluginPoint.kt new file mode 100644 index 000000000000..37d5e6a3f720 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsSubscriptionEventPluginPoint.kt @@ -0,0 +1,28 @@ +/* + * 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.impl + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.contentscopescripts.api.ContentScopeScriptsSubscriptionEventPlugin +import com.duckduckgo.di.scopes.AppScope + +@ContributesPluginPoint( + scope = AppScope::class, + boundType = ContentScopeScriptsSubscriptionEventPlugin::class, +) +@Suppress("unused") +interface ContentScopeScriptsSubscriptionEventPluginPoint diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatContentScopeJsMessageHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatContentScopeJsMessageHandler.kt new file mode 100644 index 000000000000..353eb7902811 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatContentScopeJsMessageHandler.kt @@ -0,0 +1,40 @@ +/* + * 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.duckchat.impl.ui.settings + +import com.duckduckgo.contentscopescripts.api.ContentScopeScriptsSubscriptionEventPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckChat +import com.duckduckgo.js.messaging.api.SubscriptionEventData +import com.squareup.anvil.annotations.ContributesMultibinding +import org.json.JSONObject +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class DuckChatEnabledContentScopeScriptsSubscriptionEventPlugin @Inject constructor( + private val duckChat: DuckChat, +) : ContentScopeScriptsSubscriptionEventPlugin { + + override fun getSubscriptionEventData(): SubscriptionEventData = + SubscriptionEventData( + featureName = "serpSettings", + subscriptionName = "nativeDuckAiSettingChanged", + params = JSONObject().apply { + put("enabled", duckChat.isEnabled()) + }, + ) +} 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 d411fee01f50..7b13023140fa 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 @@ -30,6 +30,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel 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.SettingsPageFeature @@ -38,6 +39,8 @@ import com.duckduckgo.settings.impl.databinding.ActivitySettingsWebviewBinding import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import logcat.logcat +import org.json.JSONObject import javax.inject.Inject import javax.inject.Named @@ -82,6 +85,13 @@ class SettingsWebViewActivity : DuckDuckGoActivity() { viewModel.onStart(url) } + + observeSubscriptionEventDataChannel() + } + + override fun onResume() { + super.onResume() + viewModel.onResume() } private fun setupBackPressedDispatcher() { @@ -113,6 +123,13 @@ class SettingsWebViewActivity : DuckDuckGoActivity() { } } + private fun observeSubscriptionEventDataChannel() { + viewModel.subscriptionEventDataFlow.onEach { subscriptionEventData -> + logcat { "SERP-Settings: Sending subscription event data to content scope scripts: $subscriptionEventData" } + contentScopeScripts.sendSubscriptionEvent(subscriptionEventData) + }.launchIn(lifecycleScope) + } + private fun exit() { binding.settingsWebView.stopLoading() binding.root.removeView(binding.settingsWebView) @@ -139,6 +156,20 @@ class SettingsWebViewActivity : DuckDuckGoActivity() { if (settingsPageFeature.serpSettingsSync().isEnabled()) { webView.webViewClient = settingsWebViewClient + + contentScopeScripts.register( + webView, + object : JsMessageCallback() { + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + // No-op + } + }, + ) } } } 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 339cccfac0ee..d071e44911b9 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 @@ -19,9 +19,14 @@ package com.duckduckgo.settings.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.contentscopescripts.api.ContentScopeScriptsSubscriptionEventPlugin import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.js.messaging.api.SubscriptionEventData +import com.duckduckgo.settings.api.SettingsPageFeature import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import logcat.LogPriority @@ -29,10 +34,17 @@ import logcat.logcat import javax.inject.Inject @ContributesViewModel(ActivityScope::class) -class SettingsWebViewViewModel @Inject constructor() : ViewModel() { +class SettingsWebViewViewModel @Inject constructor( + private val contentScopeScriptsSubscriptionEventPluginPoint: PluginPoint, + private val settingsPageFeature: SettingsPageFeature, +) : ViewModel() { + private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) val commands = commandChannel.receiveAsFlow() + private val _subscriptionEventDataChannel = Channel(capacity = Channel.BUFFERED) + val subscriptionEventDataFlow: Flow = _subscriptionEventDataChannel.receiveAsFlow() + sealed class Command { data class LoadUrl( val url: String, @@ -50,6 +62,20 @@ class SettingsWebViewViewModel @Inject constructor() : ViewModel() { } } + fun onResume() { + processContentScopeScriptsSubscriptionEventPlugin() + } + + private fun processContentScopeScriptsSubscriptionEventPlugin() { + if (settingsPageFeature.serpSettingsSync().isEnabled()) { + viewModelScope.launch { + contentScopeScriptsSubscriptionEventPluginPoint.getPlugins().forEach { plugin -> + _subscriptionEventDataChannel.send(plugin.getSubscriptionEventData()) + } + } + } + } + private fun sendCommand(command: Command) { viewModelScope.launch { commandChannel.send(command) 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 35fb9210bb5a..31a2cb57b3ef 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 @@ -16,12 +16,20 @@ package com.duckduckgo.settings.impl +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.contentscopescripts.api.ContentScopeScriptsSubscriptionEventPlugin +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.js.messaging.api.SubscriptionEventData +import com.duckduckgo.settings.api.SettingsPageFeature import com.duckduckgo.settings.impl.SettingsWebViewViewModel.Command import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -29,6 +37,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +@SuppressLint("DenyListedApi") @OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class SettingsWebViewViewModelTest { @@ -37,10 +46,17 @@ class SettingsWebViewViewModelTest { val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private lateinit var viewModel: SettingsWebViewViewModel + private lateinit var fakeContentScopeScriptsSubscriptionEventPluginPoint: FakeContentScopeScriptsSubscriptionEventPluginPoint + private var fakeSettingsPageFeature = FakeFeatureToggleFactory.create(SettingsPageFeature::class.java) @Before fun setup() { - viewModel = SettingsWebViewViewModel() + fakeContentScopeScriptsSubscriptionEventPluginPoint = FakeContentScopeScriptsSubscriptionEventPluginPoint() + + viewModel = SettingsWebViewViewModel( + contentScopeScriptsSubscriptionEventPluginPoint = fakeContentScopeScriptsSubscriptionEventPluginPoint, + settingsPageFeature = fakeSettingsPageFeature, + ) } @Test @@ -64,4 +80,102 @@ class SettingsWebViewViewModelTest { assertTrue(command is Command.Exit) } } + + @Test + fun whenOnViewResumedWithNoPluginsThenNoSubscriptionEventsSent() = runTest { + fakeSettingsPageFeature.serpSettingsSync().setRawStoredState(State(enable = true)) + + viewModel.onResume() + + viewModel.subscriptionEventDataFlow.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenOnViewResumedWithPluginsThenSubscriptionEventsSent() = runTest { + fakeSettingsPageFeature.serpSettingsSync().setRawStoredState(State(enable = true)) + val events = mutableListOf().apply { + add( + SubscriptionEventData( + featureName = "event1", + subscriptionName = "subscription1", + params = JSONObject().put("param1", "value1"), + ), + ) + add( + SubscriptionEventData( + featureName = "event2", + subscriptionName = "subscription2", + params = JSONObject().put("param2", "value2"), + ), + ) + } + + fakeContentScopeScriptsSubscriptionEventPluginPoint.addPlugins( + events.map { FakeContentScopeScriptsSubscriptionEventPlugin(it) }, + ) + + viewModel.onResume() + + viewModel.subscriptionEventDataFlow.test { + for (expectedEvent in events) { + val emittedEvent = awaitItem() + assertEquals(expectedEvent.featureName, emittedEvent.featureName) + assertEquals(expectedEvent.subscriptionName, emittedEvent.subscriptionName) + assertEquals(expectedEvent.params.toString(), emittedEvent.params.toString()) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenOnViewResumedWithPluginsAndSerpSettingsFeatureFlagOffThenNoEventsSent() = runTest { + fakeSettingsPageFeature.serpSettingsSync().setRawStoredState(State(enable = false)) + val events = mutableListOf().apply { + add( + SubscriptionEventData( + featureName = "event1", + subscriptionName = "subscription1", + params = JSONObject().put("param1", "value1"), + ), + ) + add( + SubscriptionEventData( + featureName = "event2", + subscriptionName = "subscription2", + params = JSONObject().put("param2", "value2"), + ), + ) + } + + fakeContentScopeScriptsSubscriptionEventPluginPoint.addPlugins( + events.map { FakeContentScopeScriptsSubscriptionEventPlugin(it) }, + ) + + viewModel.onResume() + + viewModel.subscriptionEventDataFlow.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } +} + +class FakeContentScopeScriptsSubscriptionEventPlugin( + private val eventData: SubscriptionEventData, +) : ContentScopeScriptsSubscriptionEventPlugin { + override fun getSubscriptionEventData(): SubscriptionEventData = eventData +} + +class FakeContentScopeScriptsSubscriptionEventPluginPoint : PluginPoint { + + private val plugins: MutableList = mutableListOf() + + fun addPlugins(plugins: List) { + this.plugins.addAll(plugins) + } + + override fun getPlugins(): Collection = plugins }