Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -758,6 +760,8 @@ class BrowserTabViewModelTest {

whenever(mockSiteErrorHandlerKillSwitch.self()).thenReturn(mockSiteErrorHandlerKillSwitchToggle)

fakeContentScopeScriptsSubscriptionEventPluginPoint = FakeContentScopeScriptsSubscriptionEventPluginPoint()

testee =
BrowserTabViewModel(
statisticsUpdater = mockStatisticsUpdater,
Expand Down Expand Up @@ -849,6 +853,8 @@ class BrowserTabViewModelTest {
addressBarTrackersAnimationFeatureToggle = mockAddressBarTrackersAnimationFeatureToggle,
autoconsentPixelManager = mockAutoconsentPixelManager,
omnibarFeatureRepository = mockOmnibarFeatureRepository,
contentScopeScriptsSubscriptionEventPluginPoint = fakeContentScopeScriptsSubscriptionEventPluginPoint,
settingsPageFeature = fakeSettingsPageFeature,
)

testee.loadData("abc", null, false, false)
Expand Down Expand Up @@ -7874,74 +7880,102 @@ class BrowserTabViewModelTest {
override fun getCustomHeaders(url: String): Map<String, String> = 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<AddDocumentStartJavaScriptPlugin> {
val cssPlugin = FakeAddDocumentStartJavaScriptPlugin("contentScopeScripts")
val otherPlugin = FakeAddDocumentStartJavaScriptPlugin("test")
class FakeContentScopeScriptsSubscriptionEventPluginPoint : PluginPoint<ContentScopeScriptsSubscriptionEventPlugin> {

override fun getPlugins() = listOf(cssPlugin, otherPlugin)
private val plugins: MutableList<ContentScopeScriptsSubscriptionEventPlugin> = mutableListOf()

fun addPlugins(plugins: List<ContentScopeScriptsSubscriptionEventPlugin>) {
this.plugins.addAll(plugins)
}

override fun getPlugins(): Collection<ContentScopeScriptsSubscriptionEventPlugin> = 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<SubscriptionEventData>().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<WebMessagingPlugin> {
val plugin = FakeWebMessagingPlugin()
testee.onViewResumed()

override fun getPlugins(): Collection<WebMessagingPlugin> = 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<SubscriptionEventData>().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<PostMessageWrapperPlugin> {
val plugin = FakePostMessageWrapperPlugin()
testee.onViewResumed()

override fun getPlugins(): Collection<PostMessageWrapperPlugin> = listOf(plugin)
testee.subscriptionEventDataFlow.test {
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -490,6 +495,8 @@ class BrowserTabViewModel @Inject constructor(
private val addressBarTrackersAnimationFeatureToggle: AddressBarTrackersAnimationFeatureToggle,
private val autoconsentPixelManager: AutoconsentPixelManager,
private val omnibarFeatureRepository: OmnibarFeatureRepository,
private val contentScopeScriptsSubscriptionEventPluginPoint: PluginPoint<ContentScopeScriptsSubscriptionEventPlugin>,
private val settingsPageFeature: SettingsPageFeature,
) : ViewModel(),
WebViewClientListener,
EditSavedSiteListener,
Expand Down Expand Up @@ -539,6 +546,9 @@ class BrowserTabViewModel @Inject constructor(

private var activeExperiments: List<Toggle>? = null

private val _subscriptionEventDataChannel = Channel<SubscriptionEventData>(capacity = Channel.BUFFERED)
val subscriptionEventDataFlow: Flow<SubscriptionEventData> = _subscriptionEventDataChannel.receiveAsFlow()

data class HiddenBookmarksIds(
val favorites: List<String> = emptyList(),
val bookmarks: List<String> = emptyList(),
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -535,7 +532,6 @@ class BrowserViewModelTest {
showOnAppLaunchOptionHandler = showOnAppLaunchOptionHandler,
additionalDefaultBrowserPrompts = mockAdditionalDefaultBrowserPrompts,
swipingTabsFeature = swipingTabsFeatureProvider,
duckChat = mockDuckChat,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading