Skip to content

Commit da235e2

Browse files
authored
SERP Settings Sync: Add event when Duck Ai setting changed (#7052)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1207908166761516/task/1211756086814103?focus=true ### Description Added support for sending subscription events to ContentScopeScripts. This enables real-time updates to the SERP settings when DuckChat settings change. The implementation includes: - Created a new `ContentScopeScriptsSubscriptionEventPlugin` interface to allow features to provide subscription event data - Added a plugin point for subscription event plugins - Implemented a DuckChat-specific plugin that sends the enabled state to Content Scope Scripts - Added subscription event channels and flows in both BrowserTabViewModel and SettingsWebViewViewModel - Set up observers in BrowserTabFragment and SettingsWebViewActivity to send events to Content Scope Scripts ### Steps to test this PR _DuckChat Settings Sync_ _from_ _Browser_ - [x] Enable `serpSettingsSync`​ feature toggle - [x] Do a search e.g. "FPL" - [x] Open Settings -> AI Features - [x] Disable/enable Duck.AI - [x] Navigate back to SERP - [x] Verify that the subscription event is sent to Content Scope Scripts (check logs for "SERP-Settings: Sending subscription event data") with the correct state - [x] Open Settings -> AI Features - [x] Disable/enable Duck.AI - [x] Verify that the subscription event is sent to Content Scope Scripts (check logs for "SERP-Settings: Sending subscription event data") with the correct state _DuckChat Settings Sync_ _from_ _SettingsWebView_ - [x] Enable `serpSettingsSync`​ feature toggle - [x] Open Settings -> AI Features - [x] Note the current state of Duck.AI - [x] Click "Search Assist Settings" - [x] Press back - [x] Verify that the subscription event is sent to Content Scope Scripts (check logs for "SERP-Settings: Sending subscription event data") with the correct state - [x] Disable/enable Duck.AI - [x] Click "Search Assist Settings" - [x] Press back - [x] Verify that the subscription event is sent to Content Scope Scripts (check logs for "SERP-Settings: Sending subscription event data") with the correct state ### UI changes N/A
1 parent 9d09622 commit da235e2

File tree

11 files changed

+386
-61
lines changed

11 files changed

+386
-61
lines changed

app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt

Lines changed: 87 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import androidx.lifecycle.Observer
4343
import androidx.room.Room
4444
import androidx.test.filters.SdkSuppress
4545
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
46+
import app.cash.turbine.test
4647
import com.duckduckgo.adclick.api.AdClickManager
4748
import com.duckduckgo.app.ValueCaptorObserver
4849
import com.duckduckgo.app.accessibility.data.AccessibilitySettingsDataStore
@@ -233,6 +234,7 @@ import com.duckduckgo.common.utils.baseHost
233234
import com.duckduckgo.common.utils.device.DeviceInfo
234235
import com.duckduckgo.common.utils.plugins.PluginPoint
235236
import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider
237+
import com.duckduckgo.contentscopescripts.api.ContentScopeScriptsSubscriptionEventPlugin
236238
import com.duckduckgo.downloads.api.DownloadStateListener
237239
import com.duckduckgo.downloads.api.FileDownloader
238240
import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload
@@ -259,12 +261,8 @@ import com.duckduckgo.feature.toggles.api.Toggle
259261
import com.duckduckgo.feature.toggles.api.Toggle.State
260262
import com.duckduckgo.history.api.HistoryEntry.VisitedPage
261263
import com.duckduckgo.history.api.NavigationHistory
262-
import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScriptPlugin
263264
import com.duckduckgo.js.messaging.api.JsCallbackData
264-
import com.duckduckgo.js.messaging.api.PostMessageWrapperPlugin
265265
import com.duckduckgo.js.messaging.api.SubscriptionEventData
266-
import com.duckduckgo.js.messaging.api.WebMessagingPlugin
267-
import com.duckduckgo.js.messaging.api.WebViewCompatMessageCallback
268266
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed
269267
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE
270268
import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels
@@ -290,6 +288,7 @@ import com.duckduckgo.savedsites.api.models.SavedSite.Favorite
290288
import com.duckduckgo.savedsites.impl.SavedSitesPixelName
291289
import com.duckduckgo.serp.logos.api.SerpEasterEggLogosToggles
292290
import com.duckduckgo.serp.logos.api.SerpLogo
291+
import com.duckduckgo.settings.api.SettingsPageFeature
293292
import com.duckduckgo.site.permissions.api.SitePermissionsManager
294293
import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest
295294
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse
@@ -619,6 +618,9 @@ class BrowserTabViewModelTest {
619618

620619
private val mockDeviceAppLookup: DeviceAppLookup = mock()
621620

621+
private lateinit var fakeContentScopeScriptsSubscriptionEventPluginPoint: FakeContentScopeScriptsSubscriptionEventPluginPoint
622+
private var fakeSettingsPageFeature = FakeFeatureToggleFactory.create(SettingsPageFeature::class.java)
623+
622624
@Before
623625
fun before() =
624626
runTest {
@@ -758,6 +760,8 @@ class BrowserTabViewModelTest {
758760

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

763+
fakeContentScopeScriptsSubscriptionEventPluginPoint = FakeContentScopeScriptsSubscriptionEventPluginPoint()
764+
761765
testee =
762766
BrowserTabViewModel(
763767
statisticsUpdater = mockStatisticsUpdater,
@@ -849,6 +853,8 @@ class BrowserTabViewModelTest {
849853
addressBarTrackersAnimationFeatureToggle = mockAddressBarTrackersAnimationFeatureToggle,
850854
autoconsentPixelManager = mockAutoconsentPixelManager,
851855
omnibarFeatureRepository = mockOmnibarFeatureRepository,
856+
contentScopeScriptsSubscriptionEventPluginPoint = fakeContentScopeScriptsSubscriptionEventPluginPoint,
857+
settingsPageFeature = fakeSettingsPageFeature,
852858
)
853859

854860
testee.loadData("abc", null, false, false)
@@ -7874,74 +7880,102 @@ class BrowserTabViewModelTest {
78747880
override fun getCustomHeaders(url: String): Map<String, String> = headers
78757881
}
78767882

7877-
class FakeAddDocumentStartJavaScriptPlugin(
7878-
override val context: String,
7879-
) : AddDocumentStartJavaScriptPlugin {
7880-
var countInitted = 0
7881-
private set
7882-
7883-
override suspend fun addDocumentStartJavaScript(webView: WebView) {
7884-
countInitted++
7885-
}
7883+
class FakeContentScopeScriptsSubscriptionEventPlugin(
7884+
private val eventData: SubscriptionEventData,
7885+
) : ContentScopeScriptsSubscriptionEventPlugin {
7886+
override fun getSubscriptionEventData(): SubscriptionEventData = eventData
78867887
}
78877888

7888-
class FakeAddDocumentStartJavaScriptPluginPoint : PluginPoint<AddDocumentStartJavaScriptPlugin> {
7889-
val cssPlugin = FakeAddDocumentStartJavaScriptPlugin("contentScopeScripts")
7890-
val otherPlugin = FakeAddDocumentStartJavaScriptPlugin("test")
7889+
class FakeContentScopeScriptsSubscriptionEventPluginPoint : PluginPoint<ContentScopeScriptsSubscriptionEventPlugin> {
78917890

7892-
override fun getPlugins() = listOf(cssPlugin, otherPlugin)
7891+
private val plugins: MutableList<ContentScopeScriptsSubscriptionEventPlugin> = mutableListOf()
7892+
7893+
fun addPlugins(plugins: List<ContentScopeScriptsSubscriptionEventPlugin>) {
7894+
this.plugins.addAll(plugins)
7895+
}
7896+
7897+
override fun getPlugins(): Collection<ContentScopeScriptsSubscriptionEventPlugin> = plugins
78937898
}
78947899

7895-
class FakeWebMessagingPlugin : WebMessagingPlugin {
7896-
var registered = false
7897-
private set
7900+
@Test
7901+
fun whenOnViewResumedWithNoPluginsThenNoSubscriptionEventsSent() = runTest {
7902+
fakeSettingsPageFeature.serpSettingsSync().setRawStoredState(State(enable = true))
78987903

7899-
override suspend fun unregister(webView: WebView) {
7900-
registered = false
7901-
}
7904+
testee.onViewResumed()
79027905

7903-
override suspend fun register(
7904-
jsMessageCallback: WebViewCompatMessageCallback,
7905-
webView: WebView,
7906-
) {
7907-
registered = true
7906+
testee.subscriptionEventDataFlow.test {
7907+
expectNoEvents()
7908+
cancelAndIgnoreRemainingEvents()
79087909
}
7910+
}
79097911

7910-
override suspend fun postMessage(
7911-
webView: WebView,
7912-
subscriptionEventData: SubscriptionEventData,
7913-
) {
7912+
@Test
7913+
fun whenOnViewResumedWithPluginsThenSubscriptionEventsSent() = runTest {
7914+
fakeSettingsPageFeature.serpSettingsSync().setRawStoredState(State(enable = true))
7915+
val events = mutableListOf<SubscriptionEventData>().apply {
7916+
add(
7917+
SubscriptionEventData(
7918+
featureName = "event1",
7919+
subscriptionName = "subscription1",
7920+
params = JSONObject().put("param1", "value1"),
7921+
),
7922+
)
7923+
add(
7924+
SubscriptionEventData(
7925+
featureName = "event2",
7926+
subscriptionName = "subscription2",
7927+
params = JSONObject().put("param2", "value2"),
7928+
),
7929+
)
79147930
}
79157931

7916-
override val context: String
7917-
get() = "test"
7918-
}
7932+
fakeContentScopeScriptsSubscriptionEventPluginPoint.addPlugins(
7933+
events.map { FakeContentScopeScriptsSubscriptionEventPlugin(it) },
7934+
)
79197935

7920-
class FakeWebMessagingPluginPoint : PluginPoint<WebMessagingPlugin> {
7921-
val plugin = FakeWebMessagingPlugin()
7936+
testee.onViewResumed()
79227937

7923-
override fun getPlugins(): Collection<WebMessagingPlugin> = listOf(plugin)
7938+
testee.subscriptionEventDataFlow.test {
7939+
for (expectedEvent in events) {
7940+
val emittedEvent = awaitItem()
7941+
assertEquals(expectedEvent.featureName, emittedEvent.featureName)
7942+
assertEquals(expectedEvent.subscriptionName, emittedEvent.subscriptionName)
7943+
assertEquals(expectedEvent.params.toString(), emittedEvent.params.toString())
7944+
}
7945+
cancelAndIgnoreRemainingEvents()
7946+
}
79247947
}
79257948

7926-
class FakePostMessageWrapperPlugin : PostMessageWrapperPlugin {
7927-
var postMessageCalled = false
7928-
private set
7929-
7930-
override suspend fun postMessage(
7931-
message: SubscriptionEventData,
7932-
webView: WebView,
7933-
) {
7934-
postMessageCalled = true
7949+
@Test
7950+
fun whenOnViewResumedWithPluginsAndSerpSettingsFeatureFlagOffThenNoEventsSent() = runTest {
7951+
fakeSettingsPageFeature.serpSettingsSync().setRawStoredState(State(enable = false))
7952+
val events = mutableListOf<SubscriptionEventData>().apply {
7953+
add(
7954+
SubscriptionEventData(
7955+
featureName = "event1",
7956+
subscriptionName = "subscription1",
7957+
params = JSONObject().put("param1", "value1"),
7958+
),
7959+
)
7960+
add(
7961+
SubscriptionEventData(
7962+
featureName = "event2",
7963+
subscriptionName = "subscription2",
7964+
params = JSONObject().put("param2", "value2"),
7965+
),
7966+
)
79357967
}
79367968

7937-
override val context: String
7938-
get() = "contentScopeScripts"
7939-
}
7969+
fakeContentScopeScriptsSubscriptionEventPluginPoint.addPlugins(
7970+
events.map { FakeContentScopeScriptsSubscriptionEventPlugin(it) },
7971+
)
79407972

7941-
class FakePostMessageWrapperPluginPoint : PluginPoint<PostMessageWrapperPlugin> {
7942-
val plugin = FakePostMessageWrapperPlugin()
7973+
testee.onViewResumed()
79437974

7944-
override fun getPlugins(): Collection<PostMessageWrapperPlugin> = listOf(plugin)
7975+
testee.subscriptionEventDataFlow.test {
7976+
expectNoEvents()
7977+
cancelAndIgnoreRemainingEvents()
7978+
}
79457979
}
79467980

79477981
@Test

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,15 @@ class BrowserTabFragment :
992992
pendingUploadTask = null
993993
}
994994
viewModel.handleExternalLaunch(isLaunchedFromExternalApp)
995+
996+
observeSubscriptionEventDataChannel()
997+
}
998+
999+
private fun observeSubscriptionEventDataChannel() {
1000+
viewModel.subscriptionEventDataFlow.onEach { subscriptionEventData ->
1001+
logcat { "SERP-Settings: Sending subscription event data to content scope scripts: $subscriptionEventData" }
1002+
contentScopeScripts.sendSubscriptionEvent(subscriptionEventData)
1003+
}.launchIn(lifecycleScope)
9951004
}
9961005

9971006
private fun resumeWebView() {

app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ import com.duckduckgo.common.utils.isMobileSite
308308
import com.duckduckgo.common.utils.plugins.PluginPoint
309309
import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider
310310
import com.duckduckgo.common.utils.toDesktopUri
311+
import com.duckduckgo.contentscopescripts.api.ContentScopeScriptsSubscriptionEventPlugin
311312
import com.duckduckgo.di.scopes.FragmentScope
312313
import com.duckduckgo.downloads.api.DownloadCommand
313314
import com.duckduckgo.downloads.api.DownloadStateListener
@@ -323,6 +324,7 @@ import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED
323324
import com.duckduckgo.feature.toggles.api.Toggle
324325
import com.duckduckgo.history.api.NavigationHistory
325326
import com.duckduckgo.js.messaging.api.JsCallbackData
327+
import com.duckduckgo.js.messaging.api.SubscriptionEventData
326328
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed
327329
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE
328330
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.PHISHING
@@ -350,6 +352,7 @@ import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment.Delete
350352
import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment.EditSavedSiteListener
351353
import com.duckduckgo.serp.logos.api.SerpEasterEggLogosToggles
352354
import com.duckduckgo.serp.logos.api.SerpLogo
355+
import com.duckduckgo.settings.api.SettingsPageFeature
353356
import com.duckduckgo.site.permissions.api.SitePermissionsManager
354357
import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest
355358
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse
@@ -365,6 +368,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
365368
import kotlinx.coroutines.FlowPreview
366369
import kotlinx.coroutines.Job
367370
import kotlinx.coroutines.async
371+
import kotlinx.coroutines.channels.Channel
368372
import kotlinx.coroutines.delay
369373
import kotlinx.coroutines.flow.Flow
370374
import kotlinx.coroutines.flow.MutableStateFlow
@@ -383,6 +387,7 @@ import kotlinx.coroutines.flow.flowOn
383387
import kotlinx.coroutines.flow.launchIn
384388
import kotlinx.coroutines.flow.map
385389
import kotlinx.coroutines.flow.onEach
390+
import kotlinx.coroutines.flow.receiveAsFlow
386391
import kotlinx.coroutines.flow.stateIn
387392
import kotlinx.coroutines.launch
388393
import kotlinx.coroutines.withContext
@@ -490,6 +495,8 @@ class BrowserTabViewModel @Inject constructor(
490495
private val addressBarTrackersAnimationFeatureToggle: AddressBarTrackersAnimationFeatureToggle,
491496
private val autoconsentPixelManager: AutoconsentPixelManager,
492497
private val omnibarFeatureRepository: OmnibarFeatureRepository,
498+
private val contentScopeScriptsSubscriptionEventPluginPoint: PluginPoint<ContentScopeScriptsSubscriptionEventPlugin>,
499+
private val settingsPageFeature: SettingsPageFeature,
493500
) : ViewModel(),
494501
WebViewClientListener,
495502
EditSavedSiteListener,
@@ -539,6 +546,9 @@ class BrowserTabViewModel @Inject constructor(
539546

540547
private var activeExperiments: List<Toggle>? = null
541548

549+
private val _subscriptionEventDataChannel = Channel<SubscriptionEventData>(capacity = Channel.BUFFERED)
550+
val subscriptionEventDataFlow: Flow<SubscriptionEventData> = _subscriptionEventDataChannel.receiveAsFlow()
551+
542552
data class HiddenBookmarksIds(
543553
val favorites: List<String> = emptyList(),
544554
val bookmarks: List<String> = emptyList(),
@@ -941,6 +951,14 @@ class BrowserTabViewModel @Inject constructor(
941951
lastFullSiteUrlEnabled = settingsDataStore.isFullUrlEnabled
942952
command.value = Command.RefreshOmnibar
943953
}
954+
955+
if (settingsPageFeature.serpSettingsSync().isEnabled()) {
956+
viewModelScope.launch {
957+
contentScopeScriptsSubscriptionEventPluginPoint.getPlugins().forEach { plugin ->
958+
_subscriptionEventDataChannel.send(plugin.getSubscriptionEventData())
959+
}
960+
}
961+
}
944962
}
945963

946964
fun onViewVisible() {

app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ import com.duckduckgo.common.utils.DispatcherProvider
6868
import com.duckduckgo.common.utils.SingleLiveEvent
6969
import com.duckduckgo.di.scopes.ActivityScope
7070
import com.duckduckgo.di.scopes.AppScope
71-
import com.duckduckgo.duckchat.api.DuckChat
7271
import com.duckduckgo.feature.toggles.api.Toggle
7372
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue
7473
import kotlinx.coroutines.CoroutineScope
@@ -102,7 +101,6 @@ class BrowserViewModel @Inject constructor(
102101
private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler,
103102
private val additionalDefaultBrowserPrompts: AdditionalDefaultBrowserPrompts,
104103
private val swipingTabsFeature: SwipingTabsFeatureProvider,
105-
private val duckChat: DuckChat,
106104
) : ViewModel(), CoroutineScope {
107105

108106
override val coroutineContext: CoroutineContext

app/src/test/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import com.duckduckgo.app.tabs.model.TabRepository
4242
import com.duckduckgo.common.test.CoroutineTestRule
4343
import com.duckduckgo.common.ui.tabs.SwipingTabsFeature
4444
import com.duckduckgo.common.ui.tabs.SwipingTabsFeatureProvider
45-
import com.duckduckgo.duckchat.api.DuckChat
4645
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
4746
import com.duckduckgo.feature.toggles.api.Toggle.State
4847
import kotlinx.coroutines.channels.Channel
@@ -97,8 +96,6 @@ class BrowserViewModelTest {
9796

9897
@Mock private lateinit var mockAdditionalDefaultBrowserPrompts: AdditionalDefaultBrowserPrompts
9998

100-
@Mock private lateinit var mockDuckChat: DuckChat
101-
10299
private val fakeShowOnAppLaunchFeatureToggle = FakeFeatureToggleFactory.create(ShowOnAppLaunchFeature::class.java)
103100

104101
private lateinit var testee: BrowserViewModel
@@ -535,7 +532,6 @@ class BrowserViewModelTest {
535532
showOnAppLaunchOptionHandler = showOnAppLaunchOptionHandler,
536533
additionalDefaultBrowserPrompts = mockAdditionalDefaultBrowserPrompts,
537534
swipingTabsFeature = swipingTabsFeatureProvider,
538-
duckChat = mockDuckChat,
539535
)
540536
}
541537

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.contentscopescripts.api
18+
19+
import com.duckduckgo.js.messaging.api.SubscriptionEventData
20+
21+
/**
22+
* Use this interface to create a new plugin that will provide [SubscriptionEventData] that can be sent to C-S-S
23+
*/
24+
interface ContentScopeScriptsSubscriptionEventPlugin {
25+
26+
/**
27+
* This method returns a [SubscriptionEventData] that can be sent to C-S-S
28+
* @return [SubscriptionEventData]
29+
*/
30+
fun getSubscriptionEventData(): SubscriptionEventData
31+
}

0 commit comments

Comments
 (0)