Skip to content

Support message responses #6602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: cris/adsjs/support-messaging
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<JsCallbackData, JSONObject>() {

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"}}"""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,7 +43,8 @@ interface GlobalJsMessageHandler {
*/
fun process(
jsMessage: JsMessage,
jsMessageCallback: JsMessageCallback,
jsMessageCallback: AdsjsJsMessageCallback,
onResponse: (JSONObject) -> Unit,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,25 @@ 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
import javax.inject.Named
import logcat.LogPriority.ERROR
import logcat.asLog
import logcat.logcat
import org.json.JSONObject

@ContributesBinding(ActivityScope::class)
@Named("AdsjsContentScopeScripts")
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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

Expand All @@ -94,6 +110,7 @@ class AdsjsContentScopeMessaging @Inject constructor(
process(
message.data ?: "",
jsMessageCallback,
replyProxy,
)
}
true
Expand All @@ -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" }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}" }
Expand All @@ -41,6 +43,7 @@ class DebugFlagGlobalHandler @Inject constructor() : GlobalContentScopeJsMessage
method = jsMessage.method,
id = jsMessage.id,
data = jsMessage.params,
onResponse = onResponse,
)
}
}
Expand Down
Loading
Loading