diff --git a/.rules/web-messaging.md b/.rules/web-messaging.md new file mode 100644 index 000000000000..6f82d5d7be89 --- /dev/null +++ b/.rules/web-messaging.md @@ -0,0 +1,222 @@ +--- +title: "WebMessaging" +description: "How we use WebMessaging for JavaScript communication" +keywords: ["webmessaging", "webMessaging", "WebMessaging", "web messaging", "javascript", "js", "WebView", "webview", "js-messaging", "js messaging", "message handlers", "subscription events", "WebViewCompat", "messaging strategy", "messaging", "js messaging", "javascript messaging", "JavaScript messaging", "javaScript messaging", "new message handler", "new messaging interface", "WebMessagingDelegate", ] +alwaysApply: false +--- + +# Using WebMessaging for JavaScript Communication + +There are 2 ways of implementing WebMessaging functionality: +1. (Recommended) Using `WebMessagingDelegate` to implement `WebMessaging` via delegation pattern. This approach already takes care of managing WebView lifecycle, as well as supporting `WebViewCompatMessageHandler` and `GlobalJsMessageHandler` for a standardized way to handle messaging across different features . +2. (Manual approach, only recommended if more flexibility than the one provided by the delegate is needed) Manually implementing `WebMessaging` + +## Using `WebMessagingDelegate` to implement `WebMessaging` via delegation pattern + +```kotlin +class WebMessaging @Inject constructor( + webMessagingDelegate: WebMessagingDelegate, +) : WebMessaging by webMessagingDelegate.createPlugin( + object : WebMessagingStrategy { + override val context: String + get() = TODO("Return a string representing the context of this messaging implementation, e.g., \"YourFeature\"") + + override val allowedDomains: Set + get() = TODO("Return the set of allowed domains for messaging. For example:" + + "- if messaging should work on all domains, return setOf(\"*\")" + + "- if messaging should work only on specific domains, return setOf(\"https://example.com\", \"https://another.com\")" + + "- if messaging should work on all subdomains of a domain, return setOf(\"https://*.example.com\")") + + override val objectName: String + get() = TODO("Return the JavaScript object name that will be available in the WebView, e.g., \"YourFeatureMessaging\"") + + override suspend fun canHandleMessaging(): Boolean { + TODO("Implement logic to determine if messaging can be handled (i.e. checking feature flags or user settings)" + + "or return true if always applicable") + } + + override fun getMessageHandlers(): List { + TODO("Return the list of message handlers that will process incoming JavaScript messages") + } + + override fun getGlobalMessageHandler(): List { + TODO("Return the list of global message handlers that should always be processed" + + "regardless of whether a specific feature handler matches the message. For example DebugFlagGlobalHandler") + } + }, +) +``` + +## Manually implementing `WebMessaging` + +Since the `WebMessagingDelegate` already solves most of the issues and dangers of working with JavaScript messaging, manual implementation isn't recommended. If absolutely necessary, having a look at `RealWebMessagingDelegate` is recommended, in order to replicate some best practices: +* Always check WebView lifecycle before registering/unregistering handlers +* Ensure thread safety when working with WebView operations +* Use `WebViewCompatWrapper` instead of calling `WebViewCompat` directly, as it includes several checks on the `WebView` lifecycle and ensures proper threading is used + +# Adding WebMessaging to the browser (DuckDuckGoWebView/BrowserTabFragment) + +If you need your messaging functionality to be available on the main browser WebView, you need to create a browser plugin that wraps your `WebMessaging` implementation. + +## Step 1: Create the core implementation + +Follow the patterns described in the [delegation pattern section](#using-webmessagingdelegate-to-implement-webmessaging-via-delegation-pattern) above. + +## Step 2: Create the browser plugin wrapper + +```kotlin +@ContributesMultibinding(FragmentScope::class) +class WebMessagingBrowserPlugin @Inject constructor( + private val WebMessaging: WebMessaging, +) : WebMessagingBrowserPlugin { + override fun webMessaging(): WebMessaging = + WebMessaging +} +``` + +## How it works + +The `WebMessagingDelegate` handles lifecycle management, WebView safety checks, and proper JavaScript interface management. + +## Message Handler Implementation + +When implementing message handlers, you need to implement the appropriate plugin interfaces and follow these patterns: + +### WebViewCompatMessageHandler +```kotlin +import com.duckduckgo.contentscopescripts.api.WebViewCompatContentScopeJsMessageHandlersPlugin +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.ProcessResult +import com.duckduckgo.js.messaging.api.WebViewCompatMessageHandler +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(ActivityScope::class) +class YourFeatureMessageHandler @Inject constructor() : WebViewCompatContentScopeJsMessageHandlersPlugin { + + override fun getJsMessageHandler(): WebViewCompatMessageHandler = object : WebViewCompatMessageHandler { + + override fun process(jsMessage: JsMessage): ProcessResult? { + + TODO("Process the message and return appropriate result" + + " - Return SendToConsumer to pass message to consumer callback (normally UI layer)" + + " - Return SendResponse(response) to send direct response without going through the UI layer" + + " - Return null if no further action required. For example, if you need to store something"+ + "from the handler and don't need to send a response or notify the UI layer" + ) + } + + override val featureName: String = TODO("Return feature name that should match this handler") + override val methods: List = TODO("Return list of methods that should match this handler") + } +} +``` + +### GlobalJsMessageHandler +```kotlin +import com.duckduckgo.contentscopescripts.impl.messaging.GlobalContentScopeJsMessageHandlersPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.GlobalJsMessageHandler +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.ProcessResult +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class YourFeatureGlobalHandler @Inject constructor() : GlobalContentScopeJsMessageHandlersPlugin { + + override fun getGlobalJsMessageHandler(): GlobalJsMessageHandler = object : GlobalJsMessageHandler { + + override fun process(jsMessage: JsMessage): ProcessResult? { + TODO("Process the message and return appropriate result" + + " - Return SendToConsumer to pass message to consumer callback (normally UI layer)" + + " - Return SendResponse(response) to send direct response without going through the UI layer" + + " - Return null if no further action required. For example, if you need to store something" + + "from the handler and don't need to send a response or notify the UI layer" + ) + } + + override val method: String = TODO("Return the name of the method that should match this handler") + } +} +``` + +## Posting Messages to JavaScript + +There are 2 ways of sending messages to JavaScript +1. If you don't require backwards compatibility with the old way of handling messages, you can use `WebMessaging` directly +2. Otherwise, you can use `PostMessageWrapperPlugin` + + +**Important**: To send messages using the new `WebMessaging` interface using the [delegation pattern](#using-webmessagingdelegate-to-implement-webmessaging-via-delegation-pattern), you must first receive a message from JavaScript. This is because the delegate needs to establish a `replyProxy` to ensure proper context communication. The system automatically sets up the reply proxy when it receives an `initialPing` message from JavaScript, which allows subsequent `postMessage` calls to work correctly. If you're not using the [delegation pattern](#using-webmessagingdelegate-to-implement-webmessaging-via-delegation-pattern), establishing a `replyProxy` for message posting is still recommended to ensure messages are only received by the appropriate script. + + +### Using `WebMessaging` directly + +To send messages from native code to JavaScript using the new WebMessaging interface: + +```kotlin +// Create subscription event data +val subscriptionEventData = SubscriptionEventData( + featureName = "yourFeature", + subscriptionName = "yourEventType", + params = JSONObject().put("key", "value") +) + +// Post message to WebView +webMessaging.postMessage(webView, subscriptionEventData) +``` + +### Using `PostMessageWrapperPlugin` + +Use `PostMessageWrapperPlugin` when: +- You need to support both new and legacy messaging interfaces +- You have feature flags that determine which messaging system to use +- You want to gradually migrate from legacy to new messaging + +The plugin handles the complexity of choosing the right messaging interface, allowing consumers to simply call `postMessage()` without worrying about the underlying implementation details. + +When you need to post messages using either the new WebMessaging interface or the legacy JsMessageHelper depending on feature flags or other conditions, you can create an implementation of `PostMessageWrapperPlugin`: + +```kotlin +@ContributesMultibinding(FragmentScope::class) +class YourFeaturePostMessageWrapperPlugin @Inject constructor( + @Named("yourFeature") private val webMessaging: WebMessaging, + private val jsMessageHelper: JsMessageHelper, + private val yourFeatureFlags: YourFeatureFlags, +) : PostMessageWrapperPlugin { + + override suspend fun postMessage(message: SubscriptionEventData, webView: WebView) { + if (yourFeatureFlags.isNewMessagingEnabled()) { + // Use new WebMessaging interface + webMessaging.postMessage(webView, message) + } else { + // Use legacy JsMessageHelper + jsMessageHelper.sendSubscriptionEvent( + subscriptionEvent = SubscriptionEvent( + context = webMessaging.context, + featureName = message.featureName, + subscriptionName = message.subscriptionName, + params = message.params, + ), + callbackName = "yourCallbackName", + secret = "yourSecret", + webView = webView, + ) + } + } + + override val context: String + get() = webMessaging.context +} +``` + +In order to make sure you're only sending the message to the appropriate consumer, inject your `WebMessaging` implementation, not the entire list of available implementations. + + +## Important Notes + +- Use appropriate scoping and consider using `@SingleInstanceIn()` with appropriate scoping to make sure only one instance of `WebMessaging` exists per `WebView` +- The `context` string should be unique and descriptive + 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 4fd3554a142c..4f1e4df753c1 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -210,6 +210,7 @@ import com.duckduckgo.brokensite.api.BrokenSitePrompt import com.duckduckgo.brokensite.api.RefreshPattern import com.duckduckgo.browser.api.AddDocumentStartJavaScriptBrowserPlugin import com.duckduckgo.browser.api.UserBrowserProperties +import com.duckduckgo.browser.api.WebMessagingBrowserPlugin import com.duckduckgo.browser.api.autocomplete.AutoComplete import com.duckduckgo.browser.api.autocomplete.AutoComplete.AutoCompleteSuggestion.AutoCompleteDefaultSuggestion import com.duckduckgo.browser.api.autocomplete.AutoComplete.AutoCompleteSuggestion.AutoCompleteHistoryRelatedSuggestion.AutoCompleteHistorySearchSuggestion @@ -260,7 +261,7 @@ import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScript 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.WebMessaging import com.duckduckgo.js.messaging.api.WebViewCompatMessageCallback import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed.MALWARE @@ -613,7 +614,7 @@ class BrowserTabViewModelTest { private val mockWebView: WebView = mock() private val fakeAddDocumentStartJavaScriptPlugins = FakeAddDocumentStartJavaScriptPluginPoint() - private val fakeMessagingPlugins = FakeWebMessagingPluginPoint() + private val fakeMessagingPlugins = FakeWebMessagingBrowserPluginPoint() private val fakePostMessageWrapperPlugins = FakePostMessageWrapperPluginPoint() @Before @@ -7526,11 +7527,11 @@ class BrowserTabViewModelTest { runTest { val mockCallback = mock() val webView = DuckDuckGoWebView(context) - assertFalse(fakeMessagingPlugins.plugin.registered) + assertFalse(fakeMessagingPlugins.plugin.webMessaging().registered) testee.configureWebView(webView, mockCallback) - assertTrue(fakeMessagingPlugins.plugin.registered) + assertTrue(fakeMessagingPlugins.plugin.webMessaging().registered) } @UiThreadTest @@ -7862,35 +7863,40 @@ class BrowserTabViewModelTest { override fun getPlugins(): Collection = listOf(plugin) } - class FakeWebMessagingPlugin : WebMessagingPlugin { + class FakeWebMessaging : WebMessaging { var registered = false private set override suspend fun unregister(webView: WebView) { registered = false } - override suspend fun register( jsMessageCallback: WebViewCompatMessageCallback, webView: WebView, ) { registered = true } - override suspend fun postMessage( webView: WebView, subscriptionEventData: SubscriptionEventData, ) { } - override val context: String get() = "test" } - class FakeWebMessagingPluginPoint : PluginPoint { - val plugin = FakeWebMessagingPlugin() + class FakeWebMessagingBrowserPlugin : WebMessagingBrowserPlugin { + private val webMessaging = FakeWebMessaging() - override fun getPlugins(): Collection = listOf(plugin) + override fun webMessaging(): FakeWebMessaging = webMessaging + } + + class FakeWebMessagingBrowserPluginPoint : PluginPoint { + val plugin = FakeWebMessagingBrowserPlugin() + + override fun getPlugins(): Collection { + return listOf(plugin) + } } class FakePostMessageWrapperPlugin : PostMessageWrapperPlugin { 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 54eb64147206..ad6aebfaaa30 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -276,6 +276,7 @@ import com.duckduckgo.brokensite.api.BrokenSitePrompt import com.duckduckgo.brokensite.api.RefreshPattern import com.duckduckgo.browser.api.AddDocumentStartJavaScriptBrowserPlugin import com.duckduckgo.browser.api.UserBrowserProperties +import com.duckduckgo.browser.api.WebMessagingBrowserPlugin import com.duckduckgo.browser.api.autocomplete.AutoComplete import com.duckduckgo.browser.api.autocomplete.AutoComplete.AutoCompleteResult import com.duckduckgo.browser.api.autocomplete.AutoComplete.AutoCompleteSuggestion @@ -323,7 +324,6 @@ import com.duckduckgo.history.api.NavigationHistory 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 @@ -490,7 +490,7 @@ class BrowserTabViewModel @Inject constructor( private val vpnMenuStateProvider: VpnMenuStateProvider, private val webViewCompatWrapper: WebViewCompatWrapper, private val addDocumentStartJavascriptPlugins: PluginPoint, - private val webMessagingPlugins: PluginPoint, + private val webMessagingPlugins: PluginPoint, private val postMessageWrapperPlugins: PluginPoint, ) : ViewModel(), WebViewClientListener, @@ -4228,7 +4228,7 @@ class BrowserTabViewModel @Inject constructor( callback?.let { webMessagingPlugins.getPlugins().forEach { plugin -> - plugin.register(callback, webView) + plugin.webMessaging().register(callback, webView) } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/messaging/ContentScopeScriptsWebMessagingBrowserPlugin.kt b/app/src/main/java/com/duckduckgo/app/browser/messaging/ContentScopeScriptsWebMessagingBrowserPlugin.kt new file mode 100644 index 000000000000..22726093b86e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/messaging/ContentScopeScriptsWebMessagingBrowserPlugin.kt @@ -0,0 +1,39 @@ +/* + * 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.messaging + +import com.duckduckgo.browser.api.WebMessagingBrowserPlugin +import com.duckduckgo.contentscopescripts.impl.messaging.ContentScopeScriptsWebMessaging +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.js.messaging.api.WebMessaging +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import javax.inject.Named + +@Named("contentScopeScripts") +@SingleInstanceIn(FragmentScope::class) +@ContributesBinding(FragmentScope::class) +@ContributesMultibinding(scope = FragmentScope::class, ignoreQualifier = true) +class ContentScopeScriptsWebMessagingBrowserPlugin @Inject constructor( + private val contentScopeScriptsWebMessaging: ContentScopeScriptsWebMessaging, +) : WebMessagingBrowserPlugin { + override fun webMessaging(): WebMessaging { + return contentScopeScriptsWebMessaging + } +} diff --git a/app/src/main/java/com/duckduckgo/app/plugins/WebMessagingPluginPoint.kt b/app/src/main/java/com/duckduckgo/app/plugins/WebMessagingBrowserPluginPoint.kt similarity index 83% rename from app/src/main/java/com/duckduckgo/app/plugins/WebMessagingPluginPoint.kt rename to app/src/main/java/com/duckduckgo/app/plugins/WebMessagingBrowserPluginPoint.kt index 91b10222b4ce..b9e54525e4d6 100644 --- a/app/src/main/java/com/duckduckgo/app/plugins/WebMessagingPluginPoint.kt +++ b/app/src/main/java/com/duckduckgo/app/plugins/WebMessagingBrowserPluginPoint.kt @@ -17,12 +17,12 @@ package com.duckduckgo.app.plugins import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.browser.api.WebMessagingBrowserPlugin import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.js.messaging.api.WebMessagingPlugin @ContributesPluginPoint( scope = AppScope::class, - boundType = WebMessagingPlugin::class, + boundType = WebMessagingBrowserPlugin::class, ) @Suppress("unused") -interface UnusedWebMessagingPluginPoint +interface UnusedWebMessagingBrowserPluginPoint diff --git a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebMessagingPlugin.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/WebMessagingBrowserPlugin.kt similarity index 55% rename from js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebMessagingPlugin.kt rename to browser-api/src/main/java/com/duckduckgo/browser/api/WebMessagingBrowserPlugin.kt index d459811fd56b..9c8b0b4e8f28 100644 --- a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebMessagingPlugin.kt +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/WebMessagingBrowserPlugin.kt @@ -14,22 +14,19 @@ * limitations under the License. */ -package com.duckduckgo.js.messaging.api +package com.duckduckgo.browser.api -import android.webkit.WebView +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.js.messaging.api.WebMessaging -interface WebMessagingPlugin { - suspend fun register( - jsMessageCallback: WebViewCompatMessageCallback, - webView: WebView, - ) - - suspend fun unregister(webView: WebView) - - suspend fun postMessage( - webView: WebView, - subscriptionEventData: SubscriptionEventData, - ) - - val context: String +/** + * Interface to provide implementations of [WebMessaging] to the browser, through + * [PluginPoint]<[WebMessaging]> + */ +interface WebMessagingBrowserPlugin { + /** + * Provides an implementation of [WebMessaging] to be used by the browser. + * @return an instance of [WebMessaging] + */ + fun webMessaging(): WebMessaging } 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..8a2e109cd244 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 @@ -25,14 +25,14 @@ import com.duckduckgo.js.messaging.api.JsMessageHelper import com.duckduckgo.js.messaging.api.PostMessageWrapperPlugin import com.duckduckgo.js.messaging.api.SubscriptionEvent import com.duckduckgo.js.messaging.api.SubscriptionEventData -import com.duckduckgo.js.messaging.api.WebMessagingPlugin +import com.duckduckgo.js.messaging.api.WebMessaging import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject import javax.inject.Named @ContributesMultibinding(FragmentScope::class) class ContentScopeScriptsPostMessageWrapperPlugin @Inject constructor( - @Named("contentScopeScripts") private val webMessagingPlugin: WebMessagingPlugin, + @Named("contentScopeScripts") private val webMessaging: WebMessaging, private val jsMessageHelper: JsMessageHelper, private val coreContentScopeScripts: CoreContentScopeScripts, private val webViewCompatContentScopeScripts: WebViewCompatContentScopeScripts, @@ -43,11 +43,11 @@ class ContentScopeScriptsPostMessageWrapperPlugin @Inject constructor( webView: WebView, ) { if (webViewCompatContentScopeScripts.isEnabled()) { - webMessagingPlugin.postMessage(webView, message) + webMessaging.postMessage(webView, message) } else { jsMessageHelper.sendSubscriptionEvent( subscriptionEvent = SubscriptionEvent( - context = webMessagingPlugin.context, + context = webMessaging.context, featureName = message.featureName, subscriptionName = message.subscriptionName, params = message.params, @@ -60,5 +60,5 @@ class ContentScopeScriptsPostMessageWrapperPlugin @Inject constructor( } override val context: String - get() = webMessagingPlugin.context + get() = webMessaging.context } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessaging.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessaging.kt new file mode 100644 index 000000000000..d8b5915816f0 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessaging.kt @@ -0,0 +1,60 @@ +/* + * 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.messaging + +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.contentscopescripts.api.WebViewCompatContentScopeJsMessageHandlersPlugin +import com.duckduckgo.contentscopescripts.impl.WebViewCompatContentScopeScripts +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.js.messaging.api.GlobalJsMessageHandler +import com.duckduckgo.js.messaging.api.WebMessaging +import com.duckduckgo.js.messaging.api.WebMessagingDelegate +import com.duckduckgo.js.messaging.api.WebMessagingStrategy +import com.duckduckgo.js.messaging.api.WebViewCompatMessageHandler +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import javax.inject.Named + +@Named("contentScopeScripts") +@SingleInstanceIn(FragmentScope::class) +@ContributesBinding(FragmentScope::class) +class ContentScopeScriptsWebMessaging @Inject constructor( + handlers: PluginPoint, + globalHandlers: PluginPoint, + webViewCompatContentScopeScripts: WebViewCompatContentScopeScripts, + webMessagingDelegate: WebMessagingDelegate, +) : WebMessaging by webMessagingDelegate.createPlugin( + object : WebMessagingStrategy { + override val context: String = "contentScopeScripts" + override val allowedDomains: Set = setOf("*") + override val objectName: String + get() = "contentScopeAdsjs" + + override suspend fun canHandleMessaging(): Boolean { + return webViewCompatContentScopeScripts.isEnabled() + } + + override fun getMessageHandlers(): List { + return handlers.getPlugins().map { it.getJsMessageHandler() } + } + + override fun getGlobalMessageHandler(): List { + return globalHandlers.getPlugins().map { it.getGlobalJsMessageHandler() } + } + }, +) diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessagingPlugin.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessagingPlugin.kt deleted file mode 100644 index 0b4692b8b790..000000000000 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessagingPlugin.kt +++ /dev/null @@ -1,251 +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.contentscopescripts.impl.messaging - -import android.annotation.SuppressLint -import android.webkit.WebView -import androidx.annotation.VisibleForTesting -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.webkit.JavaScriptReplyProxy -import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper -import com.duckduckgo.common.utils.plugins.PluginPoint -import com.duckduckgo.contentscopescripts.api.WebViewCompatContentScopeJsMessageHandlersPlugin -import com.duckduckgo.contentscopescripts.impl.WebViewCompatContentScopeScripts -import com.duckduckgo.di.scopes.FragmentScope -import com.duckduckgo.js.messaging.api.JsCallbackData -import com.duckduckgo.js.messaging.api.JsMessage -import com.duckduckgo.js.messaging.api.ProcessResult.SendResponse -import com.duckduckgo.js.messaging.api.ProcessResult.SendToConsumer -import com.duckduckgo.js.messaging.api.SubscriptionEvent -import com.duckduckgo.js.messaging.api.SubscriptionEventData -import com.duckduckgo.js.messaging.api.WebMessagingPlugin -import com.duckduckgo.js.messaging.api.WebViewCompatMessageCallback -import com.squareup.anvil.annotations.ContributesBinding -import com.squareup.anvil.annotations.ContributesMultibinding -import com.squareup.moshi.Moshi -import dagger.SingleInstanceIn -import kotlinx.coroutines.launch -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat -import org.json.JSONObject -import javax.inject.Inject -import javax.inject.Named - -private const val JS_OBJECT_NAME = "contentScopeAdsjs" - -@Named("contentScopeScripts") -@SingleInstanceIn(FragmentScope::class) -@ContributesBinding(FragmentScope::class) -@ContributesMultibinding(scope = FragmentScope::class, ignoreQualifier = true) -class ContentScopeScriptsWebMessagingPlugin @Inject constructor( - private val handlers: PluginPoint, - private val globalHandlers: PluginPoint, - private val webViewCompatContentScopeScripts: WebViewCompatContentScopeScripts, - private val webViewCompatWrapper: WebViewCompatWrapper, -) : WebMessagingPlugin { - private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() - - override val context: String = "contentScopeScripts" - private val allowedDomains: Set = setOf("*") - - private var globalReplyProxy: JavaScriptReplyProxy? = null - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun process( - webView: WebView, - message: String, - jsMessageCallback: WebViewCompatMessageCallback, - replyProxy: JavaScriptReplyProxy, - ) { - try { - val adapter = moshi.adapter(JsMessage::class.java) - val jsMessage = adapter.fromJson(message) - - jsMessage?.let { - if (context == jsMessage.context) { - // Setup reply proxy so we can send subscription events - if (jsMessage.featureName == "messaging" && jsMessage.method == "initialPing") { - globalReplyProxy = replyProxy - } - - // Process global handlers first (always processed regardless of feature handlers) - globalHandlers - .getPlugins() - .map { it.getGlobalJsMessageHandler() } - .filter { it.method == jsMessage.method } - .forEach { handler -> - handler.process(jsMessage)?.let { processResult -> - when (processResult) { - is SendToConsumer -> { - sendToConsumer(webView, jsMessageCallback, jsMessage, replyProxy) - } - is SendResponse -> { - webView.findViewTreeLifecycleOwner()?.lifecycleScope?.launch { - onResponse(webView, jsMessage, replyProxy) - } - } - } - } - } - - // Process with feature handlers - handlers - .getPlugins() - .map { it.getJsMessageHandler() } - .firstOrNull { - it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName - }?.process(jsMessage) - ?.let { processResult -> - when (processResult) { - is SendToConsumer -> { - sendToConsumer(webView, jsMessageCallback, jsMessage, replyProxy) - } - is SendResponse -> { - webView.findViewTreeLifecycleOwner()?.lifecycleScope?.launch { - onResponse(webView, jsMessage, replyProxy) - } - } - } - } - } - } - } catch (e: Exception) { - logcat(ERROR) { "Exception is ${e.asLog()}" } - } - } - - private suspend fun onResponse( - webView: WebView, - jsMessage: JsMessage, - replyProxy: JavaScriptReplyProxy, - ) { - val callbackData = - JsCallbackData( - id = jsMessage.id ?: "", - params = jsMessage.params, - featureName = jsMessage.featureName, - method = jsMessage.method, - ) - onResponse(webView, callbackData, replyProxy) - } - - private fun sendToConsumer( - webView: WebView, - jsMessageCallback: WebViewCompatMessageCallback, - jsMessage: JsMessage, - replyProxy: JavaScriptReplyProxy, - ) { - jsMessageCallback.process( - context = context, - featureName = jsMessage.featureName, - method = jsMessage.method, - id = jsMessage.id ?: "", - data = jsMessage.params, - onResponse = { response: JSONObject -> - val callbackData = - JsCallbackData( - id = jsMessage.id ?: "", - params = response, - featureName = jsMessage.featureName, - method = jsMessage.method, - ) - onResponse(webView, callbackData, replyProxy) - }, - ) - } - - override suspend fun register( - jsMessageCallback: WebViewCompatMessageCallback, - webView: WebView, - ) { - if (!webViewCompatContentScopeScripts.isEnabled()) { - return - } - - runCatching { - return@runCatching webViewCompatWrapper.addWebMessageListener( - webView, - JS_OBJECT_NAME, - allowedDomains, - ) { _, message, _, _, replyProxy -> - process( - webView, - message.data ?: "", - jsMessageCallback, - replyProxy, - ) - } - }.getOrElse { exception -> - logcat(ERROR) { "Error adding WebMessageListener for contentScopeAdsjs: ${exception.asLog()}" } - } - } - - override suspend fun unregister(webView: WebView) { - if (!webViewCompatContentScopeScripts.isEnabled()) return - runCatching { - return@runCatching webViewCompatWrapper.removeWebMessageListener(webView, JS_OBJECT_NAME) - }.getOrElse { exception -> - logcat(ERROR) { - "Error removing WebMessageListener for contentScopeAdsjs: ${exception.asLog()}" - } - } - } - - @SuppressLint("RequiresFeature") - private suspend fun onResponse( - webView: WebView, - response: JsCallbackData, - replyProxy: JavaScriptReplyProxy, - ) { - runCatching { - val responseWithId = - JSONObject().apply { - put("id", response.id) - put("result", response.params) - put("featureName", response.featureName) - put("context", context) - } - webViewCompatWrapper.postMessage(webView, replyProxy, responseWithId.toString()) - } - } - - @SuppressLint("RequiresFeature") - override suspend fun postMessage( - webView: WebView, - subscriptionEventData: SubscriptionEventData, - ) { - runCatching { - if (!webViewCompatContentScopeScripts.isEnabled()) { - return - } - - val subscriptionEvent = - SubscriptionEvent( - context = context, - featureName = subscriptionEventData.featureName, - subscriptionName = subscriptionEventData.subscriptionName, - params = subscriptionEventData.params, - ).let { - moshi.adapter(SubscriptionEvent::class.java).toJson(it) - } - - webViewCompatWrapper.postMessage(webView, globalReplyProxy, subscriptionEvent) - } - } -} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/DebugFlagGlobalHandler.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/DebugFlagGlobalHandler.kt index d78c7d3c880b..fe62a33ac813 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/DebugFlagGlobalHandler.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/DebugFlagGlobalHandler.kt @@ -17,6 +17,7 @@ package com.duckduckgo.contentscopescripts.impl.messaging import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.GlobalJsMessageHandler import com.duckduckgo.js.messaging.api.JsMessage import com.duckduckgo.js.messaging.api.ProcessResult import com.duckduckgo.js.messaging.api.ProcessResult.SendToConsumer diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/GlobalContentScopeJsMessageHandlersPlugin.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/GlobalContentScopeJsMessageHandlersPlugin.kt index dbcb9150d5b4..a9a33bb6ad7f 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/GlobalContentScopeJsMessageHandlersPlugin.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/GlobalContentScopeJsMessageHandlersPlugin.kt @@ -16,8 +16,7 @@ package com.duckduckgo.contentscopescripts.impl.messaging -import com.duckduckgo.js.messaging.api.JsMessage -import com.duckduckgo.js.messaging.api.ProcessResult +import com.duckduckgo.js.messaging.api.GlobalJsMessageHandler /** * Plugin interface for global message handlers that should always be processed @@ -31,26 +30,3 @@ interface GlobalContentScopeJsMessageHandlersPlugin { */ fun getGlobalJsMessageHandler(): GlobalJsMessageHandler } - -/** - * Handler for global messages that should be processed for all features. - */ -interface GlobalJsMessageHandler { - - /** - * Processes a global message received by the WebView. - * - * This method is responsible for handling a [JsMessage] and optionally - * invoking a callback so consumers can also process the message if needed. - * - * @param jsMessage The JavaScript message to be processed. - */ - fun process( - jsMessage: JsMessage, - ): ProcessResult? - - /** - * Method this handler can process. - */ - val method: String -} 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..1be3b6602a4f 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 @@ -6,7 +6,7 @@ import com.duckduckgo.contentscopescripts.impl.WebViewCompatContentScopeScripts import com.duckduckgo.js.messaging.api.JsMessageHelper import com.duckduckgo.js.messaging.api.SubscriptionEvent import com.duckduckgo.js.messaging.api.SubscriptionEventData -import com.duckduckgo.js.messaging.api.WebMessagingPlugin +import com.duckduckgo.js.messaging.api.WebMessaging import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.Before @@ -17,7 +17,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class ContentScopeScriptsPostMessageWrapperPluginTest { - private val mockWebMessagingPlugin: WebMessagingPlugin = mock() + private val mockWebMessaging: WebMessaging = mock() private val mockJsHelper: JsMessageHelper = mock() private val mockCoreContentScopeScripts: CoreContentScopeScripts = mock() private val mockWebViewCompatContentScopeScripts: WebViewCompatContentScopeScripts = mock() @@ -39,7 +39,7 @@ class ContentScopeScriptsPostMessageWrapperPluginTest { val testee = ContentScopeScriptsPostMessageWrapperPlugin( - webMessagingPlugin = mockWebMessagingPlugin, + webMessaging = mockWebMessaging, jsMessageHelper = mockJsHelper, coreContentScopeScripts = mockCoreContentScopeScripts, webViewCompatContentScopeScripts = mockWebViewCompatContentScopeScripts, @@ -49,7 +49,7 @@ class ContentScopeScriptsPostMessageWrapperPluginTest { fun setup() { whenever(mockCoreContentScopeScripts.callbackName).thenReturn("callbackName") whenever(mockCoreContentScopeScripts.secret).thenReturn("secret") - whenever(mockWebMessagingPlugin.context).thenReturn("contentScopeScripts") + whenever(mockWebMessaging.context).thenReturn("contentScopeScripts") whenever(mockJsonObject.toString()).thenReturn("{}") } @@ -60,7 +60,7 @@ class ContentScopeScriptsPostMessageWrapperPluginTest { testee.postMessage(subscriptionEventData, mockWebView) - verify(mockWebMessagingPlugin).postMessage(mockWebView, subscriptionEventData) + verify(mockWebMessaging).postMessage(mockWebView, subscriptionEventData) } @Test diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessagingPluginTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessagingPluginTest.kt deleted file mode 100644 index 4056aa78e8fa..000000000000 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessagingPluginTest.kt +++ /dev/null @@ -1,294 +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.contentscopescripts.impl.messaging - -import android.webkit.WebView -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.webkit.JavaScriptReplyProxy -import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper -import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.common.utils.plugins.PluginPoint -import com.duckduckgo.contentscopescripts.api.WebViewCompatContentScopeJsMessageHandlersPlugin -import com.duckduckgo.contentscopescripts.impl.WebViewCompatContentScopeScripts -import com.duckduckgo.js.messaging.api.JsMessage -import com.duckduckgo.js.messaging.api.ProcessResult -import com.duckduckgo.js.messaging.api.ProcessResult.SendToConsumer -import com.duckduckgo.js.messaging.api.SubscriptionEventData -import com.duckduckgo.js.messaging.api.WebViewCompatMessageCallback -import com.duckduckgo.js.messaging.api.WebViewCompatMessageHandler -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.json.JSONObject -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.anyString -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -@RunWith(AndroidJUnit4::class) -class ContentScopeScriptsWebMessagingPluginTest { - @get:Rule - val coroutineRule = CoroutineTestRule() - - private val webViewCompatContentScopeScripts: WebViewCompatContentScopeScripts = mock() - private val handlers: PluginPoint = FakePluginPoint() - private val mockReplyProxy: JavaScriptReplyProxy = mock() - private val globalHandlers: PluginPoint = FakeGlobalHandlersPluginPoint() - private val mockWebViewCompatWrapper: WebViewCompatWrapper = mock() - private val mockWebView: WebView = mock() - private lateinit var testee: ContentScopeScriptsWebMessagingPlugin - - private class FakePluginPoint : PluginPoint { - override fun getPlugins(): Collection = listOf(FakePlugin()) - - inner class FakePlugin : WebViewCompatContentScopeJsMessageHandlersPlugin { - override fun getJsMessageHandler(): WebViewCompatMessageHandler = - object : WebViewCompatMessageHandler { - override fun process(jsMessage: JsMessage): ProcessResult = SendToConsumer - - override val featureName: String = "webCompat" - override val methods: List = listOf("webShare", "permissionsQuery") - } - } - } - - private class FakeGlobalHandlersPluginPoint : PluginPoint { - override fun getPlugins(): Collection = listOf(FakeGlobalHandlerPlugin()) - - inner class FakeGlobalHandlerPlugin : GlobalContentScopeJsMessageHandlersPlugin { - override fun getGlobalJsMessageHandler(): GlobalJsMessageHandler = - object : GlobalJsMessageHandler { - override fun process(jsMessage: JsMessage): ProcessResult = SendToConsumer - - override val method: String = "addDebugFlag" - } - } - } - - @Before - fun setUp() = - runTest { - whenever(webViewCompatContentScopeScripts.isEnabled()).thenReturn(true) - testee = - ContentScopeScriptsWebMessagingPlugin( - handlers = handlers, - globalHandlers = globalHandlers, - webViewCompatContentScopeScripts = webViewCompatContentScopeScripts, - webViewCompatWrapper = mockWebViewCompatWrapper, - ) - } - - @Test - 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() - - testee.process(mockWebView, message, callback, mockReplyProxy) - - assertEquals(1, callback.counter) - } - - @Test - fun `when processing unknown message do nothing`() = - runTest { - givenInterfaceIsRegistered() - - testee.process(mockWebView, "", callback, mockReplyProxy) - - assertEquals(0, callback.counter) - } - - @Test - fun `when feature does not match do nothing`() = - runTest { - givenInterfaceIsRegistered() - - val message = - """ - {"context":"contentScopeScripts","featureName":"test","id":"myId","method":"webShare","params":{}} - """.trimIndent() - - testee.process(mockWebView, message, callback, mockReplyProxy) - - assertEquals(0, callback.counter) - } - - @Test - fun `when id does not exist do nothing`() = - runTest { - givenInterfaceIsRegistered() - - val message = - """ - {"context":"contentScopeScripts","webCompat":"test","method":"webShare","params":{}} - """.trimIndent() - - testee.process(mockWebView, message, callback, mockReplyProxy) - - assertEquals(0, callback.counter) - } - - @Test - fun `when processing addDebugFlag message then process message`() = - runTest { - givenInterfaceIsRegistered() - - val message = - """ - {"context":"contentScopeScripts","featureName":"debugFeature","id":"debugId","method":"addDebugFlag","params":{}} - """.trimIndent() - - testee.process(mockWebView, message, callback, mockReplyProxy) - - assertEquals(1, callback.counter) - } - - @Test - fun `when registering and adsjs is disabled then do not register`() = - runTest { - whenever(webViewCompatContentScopeScripts.isEnabled()).thenReturn(false) - - testee.register(callback, mockWebView) - - verify(mockWebViewCompatWrapper, never()) - .addWebMessageListener(any(), any(), any(), any()) - } - - @Test - fun `when registering and adsjs is enabled then register`() = - runTest { - whenever(webViewCompatContentScopeScripts.isEnabled()).thenReturn(true) - - testee.register(callback, mockWebView) - - verify(mockWebViewCompatWrapper).addWebMessageListener( - eq(mockWebView), - eq("contentScopeAdsjs"), - eq(setOf("*")), - any(), - ) - } - - @Test - fun `when unregistering and adsjs is disabled then do not unregister`() = - runTest { - whenever(webViewCompatContentScopeScripts.isEnabled()).thenReturn(false) - - testee.unregister(mockWebView) - - verify(mockWebViewCompatWrapper, never()) - .removeWebMessageListener(any(), any()) - } - - @Test - fun `when unregistering and adsjs is enabled then unregister`() = - runTest { - whenever(webViewCompatContentScopeScripts.isEnabled()).thenReturn(true) - - testee.unregister(mockWebView) - - verify(mockWebViewCompatWrapper).removeWebMessageListener(mockWebView, "contentScopeAdsjs") - } - - @Test - fun `when posting message and adsjs is disabled then do not post message`() = - runTest { - whenever(webViewCompatContentScopeScripts.isEnabled()).thenReturn(false) - val eventData = SubscriptionEventData("feature", "subscription", JSONObject()) - givenInterfaceIsRegistered() - - testee.postMessage(mockWebView, eventData) - - verify(mockWebViewCompatWrapper, never()).postMessage(any(), any(), anyString()) - } - - @Test - fun `when posting message and adsjs is enabled but webView not registered then do not post message`() = - runTest { - whenever(webViewCompatContentScopeScripts.isEnabled()).thenReturn(true) - val eventData = SubscriptionEventData("feature", "subscription", JSONObject()) - - testee.postMessage(mockWebView, eventData) - - verify(mockWebViewCompatWrapper, never()).postMessage(any(), any(), anyString()) - } - - @Test - fun `when posting message and adsjs is enabled but initialPing not processes then do not post message`() = - runTest { - whenever(webViewCompatContentScopeScripts.isEnabled()).thenReturn(true) - val eventData = SubscriptionEventData("feature", "subscription", JSONObject()) - - testee.postMessage(mockWebView, eventData) - - verify(mockWebViewCompatWrapper, never()).postMessage(any(), any(), anyString()) - } - - @Test - fun `when posting message after getting initialPing and adsjs is enabled then post message`() = - runTest { - whenever(webViewCompatContentScopeScripts.isEnabled()).thenReturn(true) - val eventData = SubscriptionEventData("feature", "subscription", JSONObject()) - givenInterfaceIsRegistered() - val expectedMessage = - """ - {"context":"contentScopeScripts","featureName":"feature","params":{},"subscriptionName":"subscription"} - """.trimIndent() - - verify(mockWebView, never()).postWebMessage(any(), any()) - - testee.postMessage(mockWebView, eventData) - verify(mockWebViewCompatWrapper).postMessage(mockWebView, mockReplyProxy, expectedMessage) - } - - private val callback = - object : WebViewCompatMessageCallback { - var counter = 0 - - override fun process( - context: String, - featureName: String, - method: String, - id: String?, - data: JSONObject?, - onResponse: suspend (params: JSONObject) -> Unit, - ) { - counter++ - } - } - - private fun givenInterfaceIsRegistered() = - runTest { - testee.register(callback, mockWebView) - val initialPingMessage = - """ - {"context":"contentScopeScripts","featureName":"messaging","id":"debugId","method":"initialPing","params":{}} - """.trimIndent() - testee.process(mockWebView, initialPingMessage, callback, mockReplyProxy) - } -} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessagingTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessagingTest.kt new file mode 100644 index 000000000000..3e969fb1f6a5 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsWebMessagingTest.kt @@ -0,0 +1,106 @@ +/* + * 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.messaging + +import android.webkit.WebView +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.WebViewCompatContentScopeJsMessageHandlersPlugin +import com.duckduckgo.contentscopescripts.impl.WebViewCompatContentScopeScripts +import com.duckduckgo.js.messaging.api.SubscriptionEventData +import com.duckduckgo.js.messaging.api.WebMessaging +import com.duckduckgo.js.messaging.api.WebMessagingDelegate +import com.duckduckgo.js.messaging.api.WebViewCompatMessageCallback +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class ContentScopeScriptsWebMessagingTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val webViewCompatContentScopeScripts: WebViewCompatContentScopeScripts = mock() + private val handlers: PluginPoint = mock() + private val globalHandlers: PluginPoint = mock() + private val mockWebView: WebView = mock() + private val mockWebMessagingDelegate: WebMessagingDelegate = mock() + private val mockWebMessaging: WebMessaging = mock() + private lateinit var testee: ContentScopeScriptsWebMessaging + + @Before + fun setUp() = runTest { + whenever(mockWebMessagingDelegate.createPlugin(any())).thenReturn(mockWebMessaging) + testee = ContentScopeScriptsWebMessaging( + handlers = handlers, + globalHandlers = globalHandlers, + webViewCompatContentScopeScripts = webViewCompatContentScopeScripts, + webMessagingDelegate = mockWebMessagingDelegate, + ) + } + + @Test + fun `when register called then delegate to created plugin`() = runTest { + testee.register(callback, mockWebView) + + verify(mockWebMessaging).register(callback, mockWebView) + } + + @Test + fun `when unregister called then delegate to created plugin`() = runTest { + testee.unregister(mockWebView) + + verify(mockWebMessaging).unregister(mockWebView) + } + + @Test + fun `when postMessage called then delegate to created plugin`() = runTest { + val eventData = SubscriptionEventData("feature", "subscription", JSONObject()) + + testee.postMessage(mockWebView, eventData) + + verify(mockWebMessaging).postMessage(mockWebView, eventData) + } + + @Test + fun `when constructed then create plugin with correct strategy`() = runTest { + // Verify that the delegate's createPlugin method was called with a strategy + verify(mockWebMessagingDelegate).createPlugin(any()) + } + + private val callback = object : WebViewCompatMessageCallback { + override fun process( + context: String, + featureName: String, + method: String, + id: String?, + data: JSONObject?, + onResponse: suspend (params: JSONObject) -> Unit, + ) { + // NOOP for delegation tests + } + } +} diff --git a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebMessaging.kt b/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebMessaging.kt new file mode 100644 index 000000000000..08334561fe8c --- /dev/null +++ b/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebMessaging.kt @@ -0,0 +1,99 @@ +/* + * 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.js.messaging.api + +import android.webkit.WebView + +interface WebMessaging { + /** + * Registers the given [jsMessageCallback] to handle messages from the provided [webView]. + * @param jsMessageCallback the callback to handle incoming messages + * @param webView the WebView to register the callback with + * Notes: + * - It's not recommended to unregister and then register again on the same WebView instance. + */ + suspend fun register( + jsMessageCallback: WebViewCompatMessageCallback, + webView: WebView, + ) + + /** + * Unregisters any previously registered message handlers from the given [webView]. + * Notes: + * - This does not remove the JavaScript interface from the WebView, just the handlers. + * - It's not required to call this when the WebView is being destroyed. + * - It's not recommended to unregister and then register again on the same WebView instance. + * @param webView the WebView to unregister the handlers from + */ + suspend fun unregister(webView: WebView) + + /** + * Posts a message to the given [webView] using the provided [subscriptionEventData]. + * @param webView the WebView to which the message should be posted + * @param subscriptionEventData the data to be sent in the message + */ + suspend fun postMessage( + webView: WebView, + subscriptionEventData: SubscriptionEventData, + ) + + /** + * The context for this instance. + * This can be used to differentiate between different messaging implementations. + * @return context string + */ + val context: String +} + +interface WebMessagingDelegate { + + /** + * Creates a [WebMessaging] implementation with the given [WebMessagingStrategy]. + * @param strategy the strategy to use for web messaging behavior + * @return [WebMessaging] implementation + */ + fun createPlugin(strategy: WebMessagingStrategy): WebMessaging +} + +/** + * Strategy interface for web messaging logic. + * Allows different implementations to provide their own behavior. + */ +interface WebMessagingStrategy { + val context: String + val allowedDomains: Set + val objectName: String + + /** + * Determines whether messaging can be handled (i.e. by checking feature flags). + * @return true if messaging can be handled for this plugin, false otherwise + */ + suspend fun canHandleMessaging(): Boolean + + /** + * Provides the list of message handlers to process incoming messages. + * @return list of [WebViewCompatMessageHandler] implementations + */ + fun getMessageHandlers(): List + + /** + * Provides the list of global message handlers that should always be processed + * regardless of whether a specific feature handler matches the message. + * @return list of [GlobalJsMessageHandler] implementations + */ + fun getGlobalMessageHandler(): List +} diff --git a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebViewCompatMessaging.kt b/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebViewCompatMessaging.kt index 9b1104595249..9e2a640b58b8 100644 --- a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebViewCompatMessaging.kt +++ b/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/WebViewCompatMessaging.kt @@ -73,3 +73,26 @@ interface WebViewCompatMessageHandler { */ val methods: List } + +/** + * Handler for global messages that should be processed for all features. + */ +interface GlobalJsMessageHandler { + + /** + * Processes a global message received by the WebView. + * + * This method is responsible for handling a [JsMessage] and optionally + * invoking a callback so consumers can also process the message if needed. + * + * @param jsMessage The JavaScript message to be processed. + */ + fun process( + jsMessage: JsMessage, + ): ProcessResult? + + /** + * Method this handler can process. + */ + val method: String +} diff --git a/js-messaging/js-messaging-impl/build.gradle b/js-messaging/js-messaging-impl/build.gradle index f187bf4d0397..49f8a98cf65c 100644 --- a/js-messaging/js-messaging-impl/build.gradle +++ b/js-messaging/js-messaging-impl/build.gradle @@ -31,6 +31,7 @@ dependencies { anvil project(':anvil-compiler') implementation project(':anvil-annotations') + implementation AndroidX.webkit implementation "com.squareup.logcat:logcat:_" implementation KotlinX.coroutines.core implementation Google.dagger diff --git a/js-messaging/js-messaging-impl/src/main/java/com/duckduckgo/js/messaging/impl/RealWebMessagingDelegate.kt b/js-messaging/js-messaging-impl/src/main/java/com/duckduckgo/js/messaging/impl/RealWebMessagingDelegate.kt new file mode 100644 index 000000000000..c464529ee46a --- /dev/null +++ b/js-messaging/js-messaging-impl/src/main/java/com/duckduckgo/js/messaging/impl/RealWebMessagingDelegate.kt @@ -0,0 +1,232 @@ +/* + * 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.js.messaging.impl + +import android.annotation.SuppressLint +import android.webkit.WebView +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.webkit.JavaScriptReplyProxy +import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.ProcessResult.SendResponse +import com.duckduckgo.js.messaging.api.ProcessResult.SendToConsumer +import com.duckduckgo.js.messaging.api.SubscriptionEvent +import com.duckduckgo.js.messaging.api.SubscriptionEventData +import com.duckduckgo.js.messaging.api.WebMessaging +import com.duckduckgo.js.messaging.api.WebMessagingDelegate +import com.duckduckgo.js.messaging.api.WebMessagingStrategy +import com.duckduckgo.js.messaging.api.WebViewCompatMessageCallback +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.Moshi +import kotlinx.coroutines.launch +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import org.json.JSONObject +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class RealWebMessagingDelegate @Inject constructor( + private val webViewCompatWrapper: WebViewCompatWrapper, +) : WebMessagingDelegate { + override fun createPlugin(strategy: WebMessagingStrategy): WebMessaging { + return object : WebMessaging { + private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() + + private var globalReplyProxy: JavaScriptReplyProxy? = null + + override val context: String + get() = strategy.context + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun process( + webView: WebView, + message: String, + jsMessageCallback: WebViewCompatMessageCallback, + replyProxy: JavaScriptReplyProxy, + ) { + try { + val adapter = moshi.adapter(JsMessage::class.java) + val jsMessage = adapter.fromJson(message) + + jsMessage?.let { + if (context == jsMessage.context) { + // Setup reply proxy so we can send subscription events + if (jsMessage.featureName == "messaging" && jsMessage.method == "initialPing") { + globalReplyProxy = replyProxy + } + + // Process global handlers first (always processed regardless of feature handlers) + strategy.getGlobalMessageHandler() + .filter { it.method == jsMessage.method } + .forEach { handler -> + handler.process(jsMessage)?.let { processResult -> + when (processResult) { + is SendToConsumer -> { + sendToConsumer(webView, jsMessageCallback, jsMessage, replyProxy) + } + is SendResponse -> { + webView.findViewTreeLifecycleOwner()?.lifecycleScope?.launch { + onResponse(webView, jsMessage, replyProxy) + } + } + } + } + } + + // Process with feature handlers + strategy.getMessageHandlers().firstOrNull { + it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName + }?.process(jsMessage)?.let { processResult -> + when (processResult) { + is SendToConsumer -> { + sendToConsumer(webView, jsMessageCallback, jsMessage, replyProxy) + } + is SendResponse -> { + webView.findViewTreeLifecycleOwner()?.lifecycleScope?.launch { + onResponse(webView, jsMessage, replyProxy) + } + } + } + } + } + } + } catch (e: Exception) { + logcat(ERROR) { "Exception is ${e.asLog()}" } + } + } + + private suspend fun onResponse( + webView: WebView, + jsMessage: JsMessage, + replyProxy: JavaScriptReplyProxy, + ) { + val callbackData = JsCallbackData( + id = jsMessage.id ?: "", + params = jsMessage.params, + featureName = jsMessage.featureName, + method = jsMessage.method, + ) + onResponse(webView, callbackData, replyProxy) + } + + private fun sendToConsumer( + webView: WebView, + jsMessageCallback: WebViewCompatMessageCallback, + jsMessage: JsMessage, + replyProxy: JavaScriptReplyProxy, + ) { + jsMessageCallback.process( + context = context, + featureName = jsMessage.featureName, + method = jsMessage.method, + id = jsMessage.id ?: "", + data = jsMessage.params, + onResponse = { response: JSONObject -> + val callbackData = JsCallbackData( + id = jsMessage.id ?: "", + params = response, + featureName = jsMessage.featureName, + method = jsMessage.method, + ) + onResponse(webView, callbackData, replyProxy) + }, + ) + } + + override suspend fun register( + jsMessageCallback: WebViewCompatMessageCallback, + webView: WebView, + ) { + if (!strategy.canHandleMessaging()) { + return + } + + runCatching { + return@runCatching webViewCompatWrapper.addWebMessageListener( + webView, + strategy.objectName, + strategy.allowedDomains, + ) { _, message, _, _, replyProxy -> + process( + webView, + message.data ?: "", + jsMessageCallback, + replyProxy, + ) + } + }.getOrElse { exception -> + logcat(ERROR) { "Error adding WebMessageListener for ${strategy.objectName}: ${exception.asLog()}" } + } + } + + override suspend fun unregister( + webView: WebView, + ) { + if (!strategy.canHandleMessaging()) return + runCatching { + return@runCatching webViewCompatWrapper.removeWebMessageListener(webView, strategy.objectName) + }.getOrElse { exception -> + logcat(ERROR) { + "Error removing WebMessageListener for ${strategy.objectName}: ${exception.asLog()}" + } + } + } + + @SuppressLint("RequiresFeature") + private suspend fun onResponse( + webView: WebView, + response: JsCallbackData, + replyProxy: JavaScriptReplyProxy, + ) { + runCatching { + val responseWithId = JSONObject().apply { + put("id", response.id) + put("result", response.params) + put("featureName", response.featureName) + put("context", context) + } + webViewCompatWrapper.postMessage(webView, replyProxy, responseWithId.toString()) + } + } + + @SuppressLint("RequiresFeature") + override suspend fun postMessage(webView: WebView, subscriptionEventData: SubscriptionEventData) { + runCatching { + if (!strategy.canHandleMessaging()) { + return + } + + val subscriptionEvent = SubscriptionEvent( + context = context, + featureName = subscriptionEventData.featureName, + subscriptionName = subscriptionEventData.subscriptionName, + params = subscriptionEventData.params, + ).let { + moshi.adapter(SubscriptionEvent::class.java).toJson(it) + } + + webViewCompatWrapper.postMessage(webView, globalReplyProxy, subscriptionEvent) + } + } + } + } +} diff --git a/js-messaging/js-messaging-impl/src/test/java/com/duckduckgo/js/messaging/impl/RealWebMessagingDelegateTest.kt b/js-messaging/js-messaging-impl/src/test/java/com/duckduckgo/js/messaging/impl/RealWebMessagingDelegateTest.kt new file mode 100644 index 000000000000..51dec6baa72d --- /dev/null +++ b/js-messaging/js-messaging-impl/src/test/java/com/duckduckgo/js/messaging/impl/RealWebMessagingDelegateTest.kt @@ -0,0 +1,256 @@ +/* + * 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.js.messaging.impl + +import android.webkit.WebView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat.WebMessageListener +import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper +import com.duckduckgo.js.messaging.api.GlobalJsMessageHandler +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.ProcessResult +import com.duckduckgo.js.messaging.api.ProcessResult.SendToConsumer +import com.duckduckgo.js.messaging.api.WebMessaging +import com.duckduckgo.js.messaging.api.WebMessagingStrategy +import com.duckduckgo.js.messaging.api.WebViewCompatMessageCallback +import com.duckduckgo.js.messaging.api.WebViewCompatMessageHandler +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +class RealWebMessagingDelegateTest { + private val mockWebViewCompatWrapper: WebViewCompatWrapper = mock() + private val mockWebView: WebView = mock() + private val mockReplyProxy: JavaScriptReplyProxy = mock() + private val mockWebViewCompatMessageCallback: WebViewCompatMessageCallback = mock() + + private lateinit var testee: RealWebMessagingDelegate + private lateinit var plugin: WebMessaging + + @Before + fun setUp() = runTest { + testee = RealWebMessagingDelegate( + webViewCompatWrapper = mockWebViewCompatWrapper, + ) + } + + @Test + fun `when registering and feature enabled then register web message listener`() = runTest { + val mockStrategy = createMockStrategy(canHandleMessaging = true) + plugin = testee.createPlugin(mockStrategy) + + plugin.register(mockWebViewCompatMessageCallback, mockWebView) + + verify(mockWebViewCompatWrapper).addWebMessageListener( + eq(mockWebView), + eq("testObject"), + eq(setOf("*")), + any(), + ) + } + + @Test + fun `when registering and feature disabled then do not register`() = runTest { + val mockStrategy = createMockStrategy(canHandleMessaging = false) + plugin = testee.createPlugin(mockStrategy) + + plugin.register(mockWebViewCompatMessageCallback, mockWebView) + + verify(mockWebViewCompatWrapper, never()).addWebMessageListener(any(), any(), any(), any()) + } + + @Test + fun `when unregistering and feature enabled then unregister web message listener`() = runTest { + val mockStrategy = createMockStrategy(canHandleMessaging = true) + plugin = testee.createPlugin(mockStrategy) + + plugin.unregister(mockWebView) + + verify(mockWebViewCompatWrapper).removeWebMessageListener(mockWebView, "testObject") + } + + @Test + fun `when unregistering and feature disabled then do not unregister`() = runTest { + val mockStrategy = createMockStrategy(canHandleMessaging = false) + plugin = testee.createPlugin(mockStrategy) + + plugin.unregister(mockWebView) + + verify(mockWebViewCompatWrapper, never()).removeWebMessageListener(any(), any()) + } + + @Test + fun `when posting message and feature enabled but no initialPing then do not post message`() = runTest { + val mockStrategy = createMockStrategy(canHandleMessaging = true) + plugin = testee.createPlugin(mockStrategy) + val eventData = com.duckduckgo.js.messaging.api.SubscriptionEventData("feature", "subscription", JSONObject()) + + plugin.postMessage(mockWebView, eventData) + + verify(mockReplyProxy, never()).postMessage(anyString()) + } + + @Test + fun `when posting message and feature disabled then do not post message`() = runTest { + val mockStrategy = createMockStrategy(canHandleMessaging = false) + plugin = testee.createPlugin(mockStrategy) + val eventData = com.duckduckgo.js.messaging.api.SubscriptionEventData("feature", "subscription", JSONObject()) + + plugin.postMessage(mockWebView, eventData) + + verify(mockReplyProxy, never()).postMessage(anyString()) + } + + @Test + fun `when processing valid message then execute callback`() = runTest { + val mockStrategy = createMockStrategy(canHandleMessaging = true) + plugin = testee.createPlugin(mockStrategy) + val message = """ + {"context":"testContext","featureName":"testFeature","id":"testId","method":"testMethod","params":{}} + """.trimIndent() + plugin.register(mockWebViewCompatMessageCallback, mockWebView) + + val listenerCaptor = argumentCaptor() + + verify(mockWebViewCompatWrapper).addWebMessageListener( + eq(mockWebView), + eq("testObject"), + eq(setOf("*")), + listenerCaptor.capture(), + ) + + listenerCaptor.firstValue.onPostMessage( + mockWebView, + WebMessageCompat(message), + mock(), + true, + mockReplyProxy, + ) + + verify(mockWebViewCompatMessageCallback).process( + eq("testContext"), + eq("testFeature"), + eq("testMethod"), + eq("testId"), + any(), + any(), + ) + } + + @Test + fun `when processing invalid message then do nothing`() = runTest { + val mockStrategy = createMockStrategy(canHandleMessaging = true) + plugin = testee.createPlugin(mockStrategy) + plugin.register(mockWebViewCompatMessageCallback, mockWebView) + + val listenerCaptor = argumentCaptor() + + verify(mockWebViewCompatWrapper).addWebMessageListener( + eq(mockWebView), + eq("testObject"), + eq(setOf("*")), + listenerCaptor.capture(), + ) + + listenerCaptor.firstValue.onPostMessage( + mockWebView, + WebMessageCompat(""), + mock(), + true, + mockReplyProxy, + ) + + verify(mockWebViewCompatMessageCallback, never()).process(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `when processing message with wrong context then do nothing`() = runTest { + val mockStrategy = createMockStrategy(canHandleMessaging = true) + plugin = testee.createPlugin(mockStrategy) + val message = """ + {"context":"wrongContext","featureName":"testFeature","id":"testId","method":"testMethod","params":{}} + """.trimIndent() + + plugin.register(mockWebViewCompatMessageCallback, mockWebView) + + val listenerCaptor = argumentCaptor() + + verify(mockWebViewCompatWrapper).addWebMessageListener( + eq(mockWebView), + eq("testObject"), + eq(setOf("*")), + listenerCaptor.capture(), + ) + + listenerCaptor.firstValue.onPostMessage( + mockWebView, + WebMessageCompat(message), + mock(), + true, + mockReplyProxy, + ) + + verify(mockWebViewCompatMessageCallback, never()).process(any(), any(), any(), any(), any(), any()) + } + + private fun createMockStrategy(canHandleMessaging: Boolean): WebMessagingStrategy { + return object : WebMessagingStrategy { + override val context: String = "testContext" + override val allowedDomains: Set = setOf("*") + override val objectName: String = "testObject" + + override suspend fun canHandleMessaging(): Boolean = canHandleMessaging + + override fun getMessageHandlers(): List { + return listOf( + object : WebViewCompatMessageHandler { + override fun process(jsMessage: JsMessage): ProcessResult { + return SendToConsumer + } + + override val featureName: String = "testFeature" + override val methods: List = listOf("testMethod") + }, + ) + } + + override fun getGlobalMessageHandler(): List { + return listOf( + object : GlobalJsMessageHandler { + override fun process(jsMessage: JsMessage): ProcessResult { + return SendToConsumer + } + + override val method: String = "globalMethod" + }, + ) + } + } + } +}