diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index a11b25da64e1..e9d32c09a68b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -176,6 +176,7 @@ import com.duckduckgo.app.browser.viewstate.LoadingViewState import com.duckduckgo.app.browser.viewstate.OmnibarViewState import com.duckduckgo.app.browser.viewstate.PrivacyShieldViewState import com.duckduckgo.app.browser.viewstate.SavedSiteChangedViewState +import com.duckduckgo.app.browser.webshare.AdsjsWebShareChooser import com.duckduckgo.app.browser.webshare.WebShareChooser import com.duckduckgo.app.browser.webview.WebContentDebugging import com.duckduckgo.app.browser.webview.WebViewBlobDownloadFeature @@ -295,6 +296,7 @@ import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityResultCodes import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityResultParams import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams +import com.duckduckgo.js.messaging.api.AdsjsJsMessageCallback import com.duckduckgo.js.messaging.api.AdsjsMessaging import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.JsMessageCallback @@ -884,6 +886,13 @@ class BrowserTabFragment : contentScopeScripts.onResponse(it) } + private var currentWebShareReplyCallback: ((JSONObject) -> Unit)? = null + + private val adsJsWebShareLauncher = registerForActivityResult(AdsjsWebShareChooser()) { result -> + currentWebShareReplyCallback?.invoke(result) + currentWebShareReplyCallback = null + } + // Instantiating a private class that contains an implementation detail of BrowserTabFragment but is separated for tidiness // see discussion in https://github.com/duckduckgo/Android/pull/4027#discussion_r1433373625 private val jsOrientationHandler = JsOrientationHandler() @@ -2140,6 +2149,10 @@ class BrowserTabFragment : is Command.SendResponseToJs -> contentScopeScripts.onResponse(it.data) is Command.SendResponseToDuckPlayer -> duckPlayerScripts.onResponse(it.data) is Command.WebShareRequest -> webShareRequest.launch(it.data) + is Command.AdsjsWebShareRequest -> { + currentWebShareReplyCallback = it.onResponse + adsJsWebShareLauncher.launch(it.data) + } is Command.ScreenLock -> screenLock(it.data) is Command.ScreenUnlock -> screenUnlock() is Command.ShowFaviconsPrompt -> showFaviconsPrompt() @@ -3015,16 +3028,15 @@ class BrowserTabFragment : webViewClient.triggerJSInit(it) adsJsContentScopeScripts.register( it, - object : JsMessageCallback() { + object : AdsjsJsMessageCallback() { override fun process( featureName: String, method: String, id: String?, data: JSONObject?, + onResponse: (JSONObject) -> Unit, ) { - viewModel.processJsCallbackMessage(featureName, method, id, data, isActiveCustomTab()) { - it.url - } + viewModel.adsjsProcessJsCallbackMessage(featureName, method, id, data, onResponse) } }, ) 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 d01c35eb75fd..802648ff95e0 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -67,6 +67,7 @@ import com.duckduckgo.app.browser.certificates.BypassedSSLCertificatesRepository import com.duckduckgo.app.browser.certificates.remoteconfig.SSLCertificatesFeature import com.duckduckgo.app.browser.commands.Command import com.duckduckgo.app.browser.commands.Command.AddHomeShortcut +import com.duckduckgo.app.browser.commands.Command.AdsjsWebShareRequest import com.duckduckgo.app.browser.commands.Command.AskToAutomateFireproofWebsite import com.duckduckgo.app.browser.commands.Command.AskToDisableLoginDetection import com.duckduckgo.app.browser.commands.Command.AskToFireproofWebsite @@ -3635,6 +3636,20 @@ class BrowserTabViewModel @Inject constructor( ) } + fun adsjsProcessJsCallbackMessage( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + onResponse: (JSONObject) -> Unit, + ) { + when (method) { + "webShare" -> if (id != null && data != null) { + adsjsWebShare(featureName, method, id, data, onResponse) + } + } + } + fun processJsCallbackMessage( featureName: String, method: String, @@ -3721,6 +3736,18 @@ class BrowserTabViewModel @Inject constructor( } } + private fun adsjsWebShare( + featureName: String, + method: String, + id: String, + data: JSONObject, + onResponse: (JSONObject) -> Unit, + ) { + viewModelScope.launch(dispatchers.main()) { + command.value = AdsjsWebShareRequest(JsCallbackData(data, featureName, method, id), onResponse) + } + } + private fun permissionsQuery( featureName: String, method: String, diff --git a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt index 99f838b211d9..25ed6807b7d4 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt @@ -49,6 +49,7 @@ import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed import com.duckduckgo.privacy.dashboard.api.ui.DashboardOpener import com.duckduckgo.savedsites.api.models.SavedSite import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions +import org.json.JSONObject sealed class Command { class OpenInNewTab( @@ -249,6 +250,7 @@ sealed class Command { data class SendResponseToDuckPlayer(val data: JsCallbackData) : Command() data class SendSubscriptions(val cssData: SubscriptionEventData, val duckPlayerData: SubscriptionEventData) : Command() data class WebShareRequest(val data: JsCallbackData) : Command() + data class AdsjsWebShareRequest(val data: JsCallbackData, val onResponse: (JSONObject) -> Unit) : Command() data class ScreenLock(val data: JsCallbackData) : Command() object ScreenUnlock : Command() data object ShowFaviconsPrompt : Command() diff --git a/app/src/main/java/com/duckduckgo/app/browser/webshare/AdsjsWebShareChooser.kt b/app/src/main/java/com/duckduckgo/app/browser/webshare/AdsjsWebShareChooser.kt new file mode 100644 index 000000000000..f4726485a6ac --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/webshare/AdsjsWebShareChooser.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 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.webshare + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract +import com.duckduckgo.js.messaging.api.JsCallbackData +import org.json.JSONObject + +class AdsjsWebShareChooser : ActivityResultContract() { + + lateinit var data: JsCallbackData + override fun createIntent( + context: Context, + input: JsCallbackData, + ): Intent { + data = input + val url = runCatching { input.params.getString("url") }.getOrNull().orEmpty() + val text = runCatching { input.params.getString("text") }.getOrNull().orEmpty() + val title = runCatching { input.params.getString("title") }.getOrNull().orEmpty() + + val finalText = url.ifEmpty { text } + + val getContentIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, finalText) + if (title.isNotEmpty()) { + putExtra(Intent.EXTRA_TITLE, title) + } + } + + return Intent.createChooser(getContentIntent, title) + } + + override fun parseResult( + resultCode: Int, + intent: Intent?, + ): JSONObject { + val result = if (this::data.isInitialized) { + when (resultCode) { + Activity.RESULT_OK -> { + JSONObject(EMPTY) + } + Activity.RESULT_CANCELED -> { + JSONObject(ABORT_ERROR) + } + else -> { + JSONObject(DATA_ERROR) + } + } + } else { + JSONObject(DATA_ERROR) + } + return result + } + + companion object { + const val EMPTY = """{}""" + const val ABORT_ERROR = """{"failure":{"name":"AbortError","message":"Share canceled"}}""" + const val DATA_ERROR = """{"failure":{"name":"DataError","message":"Data not found"}}""" + } +} diff --git a/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/GlobalContentScopeJsMessageHandlersPlugin.kt b/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/GlobalContentScopeJsMessageHandlersPlugin.kt index 003384716ac1..4ce1a0700556 100644 --- a/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/GlobalContentScopeJsMessageHandlersPlugin.kt +++ b/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/GlobalContentScopeJsMessageHandlersPlugin.kt @@ -16,8 +16,9 @@ package com.duckduckgo.contentscopescripts.api +import com.duckduckgo.js.messaging.api.AdsjsJsMessageCallback import com.duckduckgo.js.messaging.api.JsMessage -import com.duckduckgo.js.messaging.api.JsMessageCallback +import org.json.JSONObject /** * Plugin interface for global message handlers that should always be processed @@ -42,7 +43,8 @@ interface GlobalJsMessageHandler { */ fun process( jsMessage: JsMessage, - jsMessageCallback: JsMessageCallback, + jsMessageCallback: AdsjsJsMessageCallback, + onResponse: (JSONObject) -> Unit, ) /** diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/AdsjsContentScopeMessaging.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/AdsjsContentScopeMessaging.kt index a9c01ca58c4d..b8a4595f691b 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/AdsjsContentScopeMessaging.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/AdsjsContentScopeMessaging.kt @@ -18,15 +18,17 @@ package com.duckduckgo.contentscopescripts.impl.messaging import android.annotation.SuppressLint import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.contentscopescripts.api.AdsjsContentScopeJsMessageHandlersPlugin import com.duckduckgo.contentscopescripts.api.GlobalContentScopeJsMessageHandlersPlugin import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.js.messaging.api.AdsjsJsMessageCallback import com.duckduckgo.js.messaging.api.AdsjsMessaging +import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.JsMessage -import com.duckduckgo.js.messaging.api.JsMessageCallback import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.Moshi import javax.inject.Inject @@ -34,6 +36,7 @@ import javax.inject.Named import logcat.LogPriority.ERROR import logcat.asLog import logcat.logcat +import org.json.JSONObject @ContributesBinding(ActivityScope::class) @Named("AdsjsContentScopeScripts") @@ -51,7 +54,8 @@ class AdsjsContentScopeMessaging @Inject constructor( private fun process( message: String, - jsMessageCallback: JsMessageCallback, + jsMessageCallback: AdsjsJsMessageCallback, + replyProxy: JavaScriptReplyProxy, ) { try { val adapter = moshi.adapter(JsMessage::class.java) @@ -64,13 +68,22 @@ class AdsjsContentScopeMessaging @Inject constructor( .map { it.getGlobalJsMessageHandler() } .filter { it.method == jsMessage.method } .forEach { handler -> - handler.process(jsMessage, jsMessageCallback) + handler.process(jsMessage, jsMessageCallback) { + } } // Process with feature handlers handlers.getPlugins().map { it.getJsMessageHandler() }.firstOrNull { it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName - }?.process(jsMessage, jsMessageCallback) + }?.process(jsMessage, jsMessageCallback) { response: JSONObject -> + val callbackData = JsCallbackData( + id = jsMessage.id ?: "", + params = response, + featureName = jsMessage.featureName, + method = jsMessage.method, + ) + onResponse(callbackData, replyProxy) + } } } } catch (e: Exception) { @@ -80,7 +93,10 @@ class AdsjsContentScopeMessaging @Inject constructor( // TODO: A/B this, don't register if the feature is not enabled @SuppressLint("AddWebMessageListenerUsage") // safeAddWebMessageListener belongs to app module - override fun register(webView: WebView, jsMessageCallback: JsMessageCallback?) { + override fun register( + webView: WebView, + jsMessageCallback: AdsjsJsMessageCallback?, + ) { if (jsMessageCallback == null) throw Exception("Callback cannot be null") this.webView = webView @@ -94,6 +110,7 @@ class AdsjsContentScopeMessaging @Inject constructor( process( message.data ?: "", jsMessageCallback, + replyProxy, ) } true @@ -105,4 +122,21 @@ class AdsjsContentScopeMessaging @Inject constructor( false } } + + private fun onResponse(response: JsCallbackData, replyProxy: JavaScriptReplyProxy) { + runCatching { + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + val responseWithId = JSONObject().apply { + put("id", response.id) + put("result", response.params) + put("featureName", response.featureName) + put("context", context) + } + + replyProxy.postMessage(responseWithId.toString()) + } else { + logcat(ERROR) { "WebMessageListener is not supported on this WebView" } + } + } + } } 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 066b3c002248..88a12eeabbe2 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 @@ -19,10 +19,11 @@ package com.duckduckgo.contentscopescripts.impl.messaging import com.duckduckgo.contentscopescripts.api.GlobalContentScopeJsMessageHandlersPlugin import com.duckduckgo.contentscopescripts.api.GlobalJsMessageHandler import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.AdsjsJsMessageCallback import com.duckduckgo.js.messaging.api.JsMessage -import com.duckduckgo.js.messaging.api.JsMessageCallback import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject +import org.json.JSONObject import logcat.logcat @ContributesMultibinding(AppScope::class) @@ -32,7 +33,8 @@ class DebugFlagGlobalHandler @Inject constructor() : GlobalContentScopeJsMessage override fun process( jsMessage: JsMessage, - jsMessageCallback: JsMessageCallback, + jsMessageCallback: AdsjsJsMessageCallback, + onResponse: (JSONObject) -> Unit, ) { if (jsMessage.method == method) { logcat { "DebugFlagGlobalHandler addDebugFlag: ${jsMessage.featureName}" } @@ -41,6 +43,7 @@ class DebugFlagGlobalHandler @Inject constructor() : GlobalContentScopeJsMessage method = jsMessage.method, id = jsMessage.id, data = jsMessage.params, + onResponse = onResponse, ) } } diff --git a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AdsjsMessaging.kt b/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AdsjsMessaging.kt index 7adb6ddf831a..90227650b270 100644 --- a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AdsjsMessaging.kt +++ b/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AdsjsMessaging.kt @@ -17,13 +17,17 @@ package com.duckduckgo.js.messaging.api import android.webkit.WebView +import org.json.JSONObject interface AdsjsMessaging { /** * Method to register the JS interface to the webView instance */ - fun register(webView: WebView, jsMessageCallback: JsMessageCallback?) + fun register( + webView: WebView, + jsMessageCallback: AdsjsJsMessageCallback?, + ) /** * Context name @@ -36,13 +40,24 @@ interface AdsjsMessaging { val allowedDomains: Set } +abstract class AdsjsJsMessageCallback { + abstract fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + onResponse: (params: JSONObject) -> Unit, + ) +} + interface AdsjsMessageHandler { /** * This method processes a [JsMessage] */ fun process( jsMessage: JsMessage, - jsMessageCallback: JsMessageCallback?, + jsMessageCallback: AdsjsJsMessageCallback?, + onResponse: (JSONObject) -> Unit, ) /** diff --git a/web-compat/web-compat-impl/src/main/java/com/duckduckgo/webcompat/impl/messaging/adsjs/AdsjsWebCompatContentScopeJsMessageHandler.kt b/web-compat/web-compat-impl/src/main/java/com/duckduckgo/webcompat/impl/messaging/adsjs/AdsjsWebCompatContentScopeJsMessageHandler.kt new file mode 100644 index 000000000000..7ae211cd6ab4 --- /dev/null +++ b/web-compat/web-compat-impl/src/main/java/com/duckduckgo/webcompat/impl/messaging/adsjs/AdsjsWebCompatContentScopeJsMessageHandler.kt @@ -0,0 +1,45 @@ +/* + * 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.webcompat.impl.messaging.adsjs + +import com.duckduckgo.contentscopescripts.api.AdsjsContentScopeJsMessageHandlersPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.AdsjsJsMessageCallback +import com.duckduckgo.js.messaging.api.AdsjsMessageHandler +import com.duckduckgo.js.messaging.api.JsMessage +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import org.json.JSONObject + +@ContributesMultibinding(AppScope::class) +class AdsjsWebCompatContentScopeJsMessageHandler @Inject constructor() : AdsjsContentScopeJsMessageHandlersPlugin { + + override fun getJsMessageHandler(): AdsjsMessageHandler = object : AdsjsMessageHandler { + + override fun process( + jsMessage: JsMessage, + jsMessageCallback: AdsjsJsMessageCallback?, + onResponse: (JSONObject) -> Unit, + ) { + if (jsMessage.id == null) return + jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id, jsMessage.params, onResponse) + } + + override val featureName: String = "webCompat" + override val methods: List = listOf("webShare", "permissionsQuery", "screenLock", "screenUnlock") + } +}