1616
1717package com.duckduckgo.app.browser
1818
19+ import android.annotation.SuppressLint
20+ import androidx.core.net.toUri
21+ import androidx.lifecycle.findViewTreeLifecycleOwner
22+ import androidx.lifecycle.lifecycleScope
23+ import androidx.webkit.JavaScriptReplyProxy
24+ import androidx.webkit.WebMessageCompat
25+ import androidx.webkit.WebViewCompat
1926import com.duckduckgo.app.browser.webview.WebViewCompatFeature
2027import com.duckduckgo.app.browser.webview.WebViewCompatFeatureSettings
2128import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper
@@ -24,15 +31,30 @@ import com.duckduckgo.di.scopes.FragmentScope
2431import com.squareup.anvil.annotations.ContributesBinding
2532import com.squareup.moshi.Moshi
2633import dagger.SingleInstanceIn
34+ import kotlinx.coroutines.delay
35+ import kotlinx.coroutines.launch
2736import kotlinx.coroutines.withContext
2837import javax.inject.Inject
2938
3039private const val delay = " \$ DELAY$"
3140private const val postInitialPing = " \$ POST_INITIAL_PING$"
3241private const val replyToNativeMessages = " \$ REPLY_TO_NATIVE_MESSAGES$"
42+ private const val objectName = " \$ OBJECT_NAME$"
3343
3444interface WebViewCompatTestHelper {
35- suspend fun configureWebViewForWebViewCompatTest (webView : DuckDuckGoWebView )
45+ suspend fun configureWebViewForWebViewCompatTest (webView : DuckDuckGoWebView , isBlobDownloadWebViewFeatureEnabled : Boolean )
46+ suspend fun handleWebViewCompatMessage (
47+ message : WebMessageCompat ,
48+ replyProxy : JavaScriptReplyProxy ,
49+ isMainFrame : Boolean ,
50+ )
51+
52+ suspend fun useBlobDownloadsMessageListener (): Boolean
53+
54+ suspend fun onPageStarted (webView : DuckDuckGoWebView ? )
55+ suspend fun onBrowserMenuButtonPressed (webView : DuckDuckGoWebView ? )
56+
57+ fun blobDownloadScriptForWebViewCompatTest (): String
3658}
3759
3860@ContributesBinding(FragmentScope ::class )
@@ -44,23 +66,201 @@ class RealWebViewCompatTestHelper @Inject constructor(
4466 moshi : Moshi ,
4567) : WebViewCompatTestHelper {
4668
69+ private var proxy: JavaScriptReplyProxy ? = null
70+
4771 private val adapter = moshi.adapter(WebViewCompatFeatureSettings ::class .java)
4872
49- override suspend fun configureWebViewForWebViewCompatTest (webView : DuckDuckGoWebView ) {
50- val script = withContext(dispatchers.io()) {
51- if (! webViewCompatFeature.self().isEnabled()) return @withContext null
73+ data class WebViewCompatConfig (
74+ val settings : WebViewCompatFeatureSettings ? ,
75+ val useBlobDownloadsMessageListener : Boolean ,
76+ val replyToInitialPing : Boolean ,
77+ val jsSendsInitialPing : Boolean ,
78+ val jsRepliesToNativeMessages : Boolean ,
79+ val sendMessageOnPageStarted : Boolean ,
80+ val sendMessageOnContextMenuOpen : Boolean ,
81+ val sendMessagesUsingReplyProxy : Boolean ,
82+ )
83+
84+ private var cachedConfig: WebViewCompatConfig ? = null
5285
53- val webViewCompatSettings = webViewCompatFeature.self().getSettings()?.let {
54- adapter.fromJson(it)
86+ private suspend fun getWebViewCompatConfig (): WebViewCompatConfig {
87+ cachedConfig?.let { return it }
88+
89+ return withContext(dispatchers.io()) {
90+ WebViewCompatConfig (
91+ settings = webViewCompatFeature.self().getSettings()?.let {
92+ adapter.fromJson(it)
93+ },
94+ useBlobDownloadsMessageListener = webViewCompatFeature.useBlobDownloadsMessageListener().isEnabled(),
95+ replyToInitialPing = webViewCompatFeature.replyToInitialPing().isEnabled(),
96+ jsSendsInitialPing = webViewCompatFeature.jsSendsInitialPing().isEnabled(),
97+ jsRepliesToNativeMessages = webViewCompatFeature.jsRepliesToNativeMessages().isEnabled(),
98+ sendMessageOnPageStarted = webViewCompatFeature.sendMessageOnPageStarted().isEnabled(),
99+ sendMessageOnContextMenuOpen = webViewCompatFeature.sendMessageOnContextMenuOpen().isEnabled(),
100+ sendMessagesUsingReplyProxy = webViewCompatFeature.sendMessagesUsingReplyProxy().isEnabled(),
101+ ).also {
102+ cachedConfig = it
55103 }
56- webView.resources?.openRawResource(R .raw.webviewcompat_test_script)?.bufferedReader().use { it?.readText() }.orEmpty()
57- .replace(delay, webViewCompatSettings?.jsInitialPingDelay?.toString() ? : " 0" )
58- .replace(postInitialPing, webViewCompatFeature.jsSendsInitialPing().isEnabled().toString())
59- .replace(replyToNativeMessages, webViewCompatFeature.jsRepliesToNativeMessages().isEnabled().toString())
104+ }
105+ }
106+
107+ override suspend fun configureWebViewForWebViewCompatTest (webView : DuckDuckGoWebView , isBlobDownloadWebViewFeatureEnabled : Boolean ) {
108+ val config = getWebViewCompatConfig()
109+
110+ val useDedicatedListener = ! config.useBlobDownloadsMessageListener || ! isBlobDownloadWebViewFeatureEnabled
111+
112+ val script = withContext(dispatchers.io()) {
113+ if (! webViewCompatFeature.self().isEnabled()) return @withContext null
114+ webView.context.resources?.openRawResource(R .raw.webviewcompat_test_script)
115+ ?.bufferedReader().use { it?.readText() }
116+ ?.replace(delay, config.settings?.jsInitialPingDelay?.toString() ? : " 0" )
117+ ?.replace(postInitialPing, config.jsSendsInitialPing.toString())
118+ ?.replace(replyToNativeMessages, config.jsRepliesToNativeMessages.toString())
119+ ?.replace(objectName, if (useDedicatedListener) " webViewCompatTestObj" else " ddgBlobDownloadObj" )
60120 } ? : return
61121
62122 withContext(dispatchers.main()) {
63123 webViewCompatWrapper.addDocumentStartJavaScript(webView, script, setOf (" *" ))
124+
125+ if (useDedicatedListener) {
126+ webViewCompatWrapper.addWebMessageListener(
127+ webView,
128+ " webViewCompatTestObj" ,
129+ setOf (" *" ),
130+ ) { view, message, sourceOrigin, isMainFrame, replyProxy ->
131+ webView.findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
132+ handleWebViewCompatMessage(message, replyProxy, isMainFrame)
133+ }
134+ }
135+ }
136+ }
137+ }
138+
139+ @SuppressLint(" PostMessageUsage" , " RequiresFeature" )
140+ override suspend fun handleWebViewCompatMessage (
141+ message : WebMessageCompat ,
142+ replyProxy : JavaScriptReplyProxy ,
143+ isMainFrame : Boolean ,
144+ ) {
145+ withContext(dispatchers.io()) {
146+ if (message.data?.startsWith(" webViewCompat Ping:" ) != true ) return @withContext
147+ val cfg = getWebViewCompatConfig()
148+ if (isMainFrame) {
149+ proxy = replyProxy
150+ }
151+ if (cfg.replyToInitialPing) {
152+ cfg.settings?.initialPingDelay?.takeIf { it > 0 }?.let {
153+ delay(it)
154+ }
155+ withContext(dispatchers.main()) {
156+ replyProxy.postMessage(" Pong from Native" )
157+ }
158+ }
64159 }
65160 }
161+
162+ override suspend fun useBlobDownloadsMessageListener (): Boolean {
163+ return withContext(dispatchers.io()) { getWebViewCompatConfig().useBlobDownloadsMessageListener }
164+ }
165+
166+ @SuppressLint(" PostMessageUsage" , " RequiresFeature" )
167+ override suspend fun onPageStarted (webView : DuckDuckGoWebView ? ) {
168+ withContext(dispatchers.main()) {
169+ val config = getWebViewCompatConfig()
170+
171+ if (! config.sendMessageOnPageStarted) return @withContext
172+
173+ val messageData = " PageStarted"
174+
175+ if (config.sendMessagesUsingReplyProxy) {
176+ proxy?.postMessage(messageData)
177+ } else {
178+ webView?.url?.let {
179+ WebViewCompat .postWebMessage(
180+ webView,
181+ WebMessageCompat (messageData),
182+ it.toUri(),
183+ )
184+ }
185+ }
186+ }
187+ }
188+
189+ @SuppressLint(" RequiresFeature" , " PostMessageUsage" )
190+ override suspend fun onBrowserMenuButtonPressed (webView : DuckDuckGoWebView ? ) {
191+ withContext(dispatchers.main()) {
192+ val config = getWebViewCompatConfig()
193+
194+ if (! config.sendMessageOnContextMenuOpen) return @withContext
195+
196+ val messageData = " ContextMenuOpened"
197+
198+ if (config.sendMessagesUsingReplyProxy) {
199+ proxy?.postMessage(messageData)
200+ } else {
201+ webView?.url?.let {
202+ WebViewCompat .postWebMessage(
203+ webView,
204+ WebMessageCompat (messageData),
205+ it.toUri(),
206+ )
207+ }
208+ }
209+ }
210+ }
211+
212+ override fun blobDownloadScriptForWebViewCompatTest (): String {
213+ val script =
214+ """
215+ (function() {
216+
217+ const urlToBlobCollection = {};
218+
219+ const original_createObjectURL = URL.createObjectURL;
220+
221+ URL.createObjectURL = function () {
222+ const blob = arguments[0];
223+ const url = original_createObjectURL.call(this, ...arguments);
224+ if (blob instanceof Blob) {
225+ urlToBlobCollection[url] = blob;
226+ }
227+ return url;
228+ }
229+
230+ function blobToBase64DataUrl(blob) {
231+ return new Promise((resolve, reject) => {
232+ const reader = new FileReader();
233+ reader.onloadend = function() {
234+ resolve(reader.result);
235+ }
236+ reader.onerror = function() {
237+ reject(new Error('Failed to read Blob object'));
238+ }
239+ reader.readAsDataURL(blob);
240+ });
241+ }
242+
243+ const pingMessage = 'Ping:' + window.location.href;
244+ window.ddgBlobDownloadObj.postMessage(pingMessage);
245+ console.log('Sent ping message for blob downloads: ' + pingMessage);
246+
247+ window.ddgBlobDownloadObj.addEventListener('message', function(event) {
248+ if (event.data.startsWith('blob:')) {
249+ console.log(event.data);
250+ const blob = urlToBlobCollection[event.data];
251+ if (blob) {
252+ blobToBase64DataUrl(blob).then((dataUrl) => {
253+ console.log('Sending data URL back to native ' + dataUrl);
254+ window.ddgBlobDownloadObj.postMessage(dataUrl);
255+ });
256+ } else {
257+ console.log('No Blob found for URL: ' + event.data);
258+ }
259+ }
260+ });
261+ })();
262+ """ .trimIndent()
263+
264+ return script
265+ }
66266}
0 commit comments