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 a52af4ecfc46..5d8b1c2f93e7 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -181,6 +181,7 @@ import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonito import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.browser.api.brokensite.BrokenSiteContext +import com.duckduckgo.browser.api.download.WebViewBlobDownloader import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.utils.DispatcherProvider @@ -469,6 +470,8 @@ class BrowserTabViewModelTest { private val subscriptions: Subscriptions = mock() + private val webViewBlobDownloader: WebViewBlobDownloader = mock() + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels = mock { runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } } @@ -639,6 +642,7 @@ class BrowserTabViewModelTest { duckPlayer = mockDuckPlayer, duckPlayerJSHelper = DuckPlayerJSHelper(mockDuckPlayer, mockAppBuildConfig, mockPixel, mockDuckDuckGoUrlDetector), loadingBarExperimentManager = loadingBarExperimentManager, + webViewBlobDownloader = webViewBlobDownloader, ) testee.loadData("abc", null, false, false) 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 6c80e0fd6232..aa05901cd1a7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -2369,7 +2369,7 @@ class BrowserTabFragment : it.setDownloadListener { url, _, contentDisposition, mimeType, _ -> lifecycleScope.launch(dispatchers.main()) { - viewModel.requestFileDownload(url, contentDisposition, mimeType, true, isBlobDownloadWebViewFeatureEnabled(it)) + viewModel.requestFileDownload(url, contentDisposition, mimeType, true, isBlobDownloadWebViewFeatureEnabled()) } } @@ -2460,9 +2460,8 @@ class BrowserTabFragment : private fun configureWebViewForBlobDownload(webView: DuckDuckGoWebView) { lifecycleScope.launch(dispatchers.main()) { - if (isBlobDownloadWebViewFeatureEnabled(webView)) { - val script = blobDownloadScript() - WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) + if (isBlobDownloadWebViewFeatureEnabled()) { + viewModel.configureWebViewForBlobDownload(webView) webView.safeAddWebMessageListener( webViewCapabilityChecker, @@ -2499,53 +2498,7 @@ class BrowserTabFragment : } } - private fun blobDownloadScript(): String { - val script = """ - window.__url_to_blob_collection = {}; - - const original_createObjectURL = URL.createObjectURL; - - URL.createObjectURL = function () { - const blob = arguments[0]; - const url = original_createObjectURL.call(this, ...arguments); - if (blob instanceof Blob) { - __url_to_blob_collection[url] = blob; - } - return url; - } - - function blobToBase64DataUrl(blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = function() { - resolve(reader.result); - } - reader.onerror = function() { - reject(new Error('Failed to read Blob object')); - } - reader.readAsDataURL(blob); - }); - } - - const pingMessage = 'Ping:' + window.location.href - ddgBlobDownloadObj.postMessage(pingMessage) - - ddgBlobDownloadObj.onmessage = function(event) { - if (event.data.startsWith('blob:')) { - const blob = window.__url_to_blob_collection[event.data]; - if (blob) { - blobToBase64DataUrl(blob).then((dataUrl) => { - ddgBlobDownloadObj.postMessage(dataUrl); - }); - } - } - } - """.trimIndent() - - return script - } - - private suspend fun isBlobDownloadWebViewFeatureEnabled(webView: DuckDuckGoWebView): Boolean { + private suspend fun isBlobDownloadWebViewFeatureEnabled(): Boolean { return withContext(dispatchers.io()) { webViewBlobDownloadFeature.self().isEnabled() } && webViewCapabilityChecker.isSupported(WebViewCapability.WebMessageListener) && webViewCapabilityChecker.isSupported(WebViewCapability.DocumentStartJavaScript) 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 6b4267d10cc0..bce3632d62cb 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -249,6 +249,7 @@ import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.MENU +import com.duckduckgo.browser.api.download.WebViewBlobDownloader import com.duckduckgo.common.utils.AppUrl import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.SingleLiveEvent @@ -308,8 +309,6 @@ import kotlin.collections.List import kotlin.collections.Map import kotlin.collections.MutableMap import kotlin.collections.any -import kotlin.collections.component1 -import kotlin.collections.component2 import kotlin.collections.contains import kotlin.collections.drop import kotlin.collections.emptyList @@ -319,7 +318,6 @@ import kotlin.collections.filterNot import kotlin.collections.firstOrNull import kotlin.collections.forEach import kotlin.collections.isNotEmpty -import kotlin.collections.iterator import kotlin.collections.map import kotlin.collections.mapOf import kotlin.collections.minus @@ -330,7 +328,6 @@ import kotlin.collections.set import kotlin.collections.setOf import kotlin.collections.take import kotlin.collections.toList -import kotlin.collections.toMutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -415,6 +412,7 @@ class BrowserTabViewModel @Inject constructor( private val duckPlayer: DuckPlayer, private val duckPlayerJSHelper: DuckPlayerJSHelper, private val loadingBarExperimentManager: LoadingBarExperimentManager, + private val webViewBlobDownloader: WebViewBlobDownloader, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -426,11 +424,6 @@ class BrowserTabViewModel @Inject constructor( private var hasUserSeenHistoryIAM = false private var lastAutoCompleteState: AutoCompleteViewState? = null - private val replyProxyMap = mutableMapOf() - - // Map>() = Map>() - private val fixedReplyProxyMap = mutableMapOf>() - data class LocationPermission( val origin: String, val callback: GeolocationPermissions.Callback, @@ -1375,7 +1368,7 @@ class BrowserTabViewModel @Inject constructor( title: String?, ) { Timber.v("Page changed: $url") - cleanupBlobDownloadReplyProxyMaps() + webViewBlobDownloader.clearReplyProxies() hasCtaBeenShownForCurrentPage.set(false) buildSiteFactory(url, title, urlUnchangedForExternalLaunchPurposes(site?.url, url)) @@ -1463,11 +1456,6 @@ class BrowserTabViewModel @Inject constructor( } } - private fun cleanupBlobDownloadReplyProxyMaps() { - fixedReplyProxyMap.clear() - replyProxyMap.clear() - } - private fun setAdClickActiveTabData(url: String?) { val sourceTabId = tabRepository.liveSelectedTab.value?.sourceTabId val sourceTabUrl = tabRepository.liveTabs.value?.firstOrNull { it.tabId == sourceTabId }?.url @@ -2989,38 +2977,12 @@ class BrowserTabViewModel @Inject constructor( command.postValue(RequestFileDownload(url, contentDisposition, mimeType, requestUserConfirmation)) } - @SuppressLint("RequiresFeature") // it's already checked in isBlobDownloadWebViewFeatureEnabled private fun postMessageToConvertBlobToDataUri(url: String) { - appCoroutineScope.launch(dispatchers.main()) { // main because postMessage is not always safe in another thread - if (withContext(dispatchers.io()) { androidBrowserConfig.fixBlobDownloadWithIframes().isEnabled() }) { - for ((key, proxies) in fixedReplyProxyMap) { - if (sameOrigin(url.removePrefix("blob:"), key)) { - for (replyProxy in proxies.values) { - replyProxy.postMessage(url) - } - return@launch - } - } - } else { - for ((key, value) in replyProxyMap) { - if (sameOrigin(url.removePrefix("blob:"), key)) { - value.postMessage(url) - return@launch - } - } - } + viewModelScope.launch { + webViewBlobDownloader.convertBlobToDataUri(url) } } - private fun sameOrigin(firstUrl: String, secondUrl: String): Boolean { - return kotlin.runCatching { - val firstUri = Uri.parse(firstUrl) - val secondUri = Uri.parse(secondUrl) - - firstUri.host == secondUri.host && firstUri.scheme == secondUri.scheme && firstUri.port == secondUri.port - }.getOrNull() ?: return false - } - fun showEmailProtectionChooseEmailPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) { emailManager.getEmailAddress()?.let { command.postValue(ShowEmailProtectionChooseEmailPrompt(it, autofillWebMessageRequest)) @@ -3643,16 +3605,8 @@ class BrowserTabViewModel @Inject constructor( } fun saveReplyProxyForBlobDownload(originUrl: String, replyProxy: JavaScriptReplyProxy, locationHref: String? = null) { - appCoroutineScope.launch(dispatchers.io()) { // FF check has disk IO - if (androidBrowserConfig.fixBlobDownloadWithIframes().isEnabled()) { - val frameProxies = fixedReplyProxyMap[originUrl]?.toMutableMap() ?: mutableMapOf() - // if location.href is not passed, we fall back to origin - val safeLocationHref = locationHref ?: originUrl - frameProxies[safeLocationHref] = replyProxy - fixedReplyProxyMap[originUrl] = frameProxies - } else { - replyProxyMap[originUrl] = replyProxy - } + viewModelScope.launch { + webViewBlobDownloader.storeReplyProxy(originUrl, replyProxy, locationHref) } } @@ -3725,6 +3679,10 @@ class BrowserTabViewModel @Inject constructor( newTabPixels.get().fireNewTabDisplayed() } + suspend fun configureWebViewForBlobDownload(webView: DuckDuckGoWebView) { + webViewBlobDownloader.addBlobDownloadSupport(webView) + } + companion object { private const val FIXED_PROGRESS = 50 diff --git a/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt b/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt index 0e4e74e89521..4d2314a377fd 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/RealWebViewCapabilityChecker.kt @@ -24,12 +24,12 @@ import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.extensions.compareSemanticVersion -import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject import kotlinx.coroutines.withContext -@ContributesBinding(FragmentScope::class) +@ContributesBinding(AppScope::class) class RealWebViewCapabilityChecker @Inject constructor( private val dispatchers: DispatcherProvider, private val webViewVersionProvider: WebViewVersionProvider, diff --git a/app/src/main/java/com/duckduckgo/app/browser/downloader/WebViewBlobDownloaderModernImpl.kt b/app/src/main/java/com/duckduckgo/app/browser/downloader/WebViewBlobDownloaderModernImpl.kt new file mode 100644 index 000000000000..d4529832ca06 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/downloader/WebViewBlobDownloaderModernImpl.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2024 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.downloader + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebViewCompat +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability +import com.duckduckgo.app.browser.webview.WebViewBlobDownloadFeature +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature +import com.duckduckgo.browser.api.download.WebViewBlobDownloader +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext + +@ContributesBinding(AppScope::class) +class WebViewBlobDownloaderModernImpl @Inject constructor( + private val webViewBlobDownloadFeature: WebViewBlobDownloadFeature, + private val dispatchers: DispatcherProvider, + private val webViewCapabilityChecker: WebViewCapabilityChecker, + private val androidBrowserConfig: AndroidBrowserConfigFeature, + +) : WebViewBlobDownloader { + + private val replyProxyMap = mutableMapOf() + + // Map>() = Map>() + private val fixedReplyProxyMap = mutableMapOf>() + + @SuppressLint("RequiresFeature") + override suspend fun addBlobDownloadSupport(webView: WebView) { + if (isBlobDownloadWebViewFeatureEnabled()) { + WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) + } + } + + @SuppressLint("RequiresFeature") + override suspend fun convertBlobToDataUri(blobUrl: String) { + if (withContext(dispatchers.io()) { androidBrowserConfig.fixBlobDownloadWithIframes().isEnabled() }) { + for ((key, proxies) in fixedReplyProxyMap) { + if (sameOrigin(blobUrl.removePrefix("blob:"), key)) { + for (replyProxy in proxies.values) { + replyProxy.postMessage(blobUrl) + } + return + } + } + } else { + for ((key, value) in replyProxyMap) { + if (sameOrigin(blobUrl.removePrefix("blob:"), key)) { + value.postMessage(blobUrl) + return + } + } + } + } + + override suspend fun storeReplyProxy( + originUrl: String, + replyProxy: JavaScriptReplyProxy, + locationHref: String?, + ) { + if (androidBrowserConfig.fixBlobDownloadWithIframes().isEnabled()) { + val frameProxies = fixedReplyProxyMap[originUrl]?.toMutableMap() ?: mutableMapOf() + // if location.href is not passed, we fall back to origin + val safeLocationHref = locationHref ?: originUrl + frameProxies[safeLocationHref] = replyProxy + fixedReplyProxyMap[originUrl] = frameProxies + } else { + replyProxyMap[originUrl] = replyProxy + } + } + + private fun sameOrigin(firstUrl: String, secondUrl: String): Boolean { + return kotlin.runCatching { + val firstUri = Uri.parse(firstUrl) + val secondUri = Uri.parse(secondUrl) + + firstUri.host == secondUri.host && firstUri.scheme == secondUri.scheme && firstUri.port == secondUri.port + }.getOrNull() ?: return false + } + + override fun clearReplyProxies() { + fixedReplyProxyMap.clear() + replyProxyMap.clear() + } + + private suspend fun isBlobDownloadWebViewFeatureEnabled(): Boolean { + return withContext(dispatchers.io()) { webViewBlobDownloadFeature.self().isEnabled() } && + webViewCapabilityChecker.isSupported(WebViewCapability.WebMessageListener) && + webViewCapabilityChecker.isSupported(WebViewCapability.DocumentStartJavaScript) + } + + companion object { + private val script = """ + window.__url_to_blob_collection = {}; + + const original_createObjectURL = URL.createObjectURL; + + URL.createObjectURL = function () { + const blob = arguments[0]; + const url = original_createObjectURL.call(this, ...arguments); + if (blob instanceof Blob) { + __url_to_blob_collection[url] = blob; + } + return url; + } + + function blobToBase64DataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = function() { + resolve(reader.result); + } + reader.onerror = function() { + reject(new Error('Failed to read Blob object')); + } + reader.readAsDataURL(blob); + }); + } + + const pingMessage = 'Ping:' + window.location.href + ddgBlobDownloadObj.postMessage(pingMessage) + + ddgBlobDownloadObj.onmessage = function(event) { + if (event.data.startsWith('blob:')) { + const blob = window.__url_to_blob_collection[event.data]; + if (blob) { + blobToBase64DataUrl(blob).then((dataUrl) => { + ddgBlobDownloadObj.postMessage(dataUrl); + }); + } + } + } + """.trimIndent() + } +} diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillScreens.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillScreens.kt index ff119db2c586..b34af241ca75 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillScreens.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillScreens.kt @@ -64,7 +64,7 @@ sealed interface AutofillScreens { } @Parcelize - data class Success(val importedCount: Int) : Result + data class Success(val importedCount: Int, val foundInImport: Int) : Result @Parcelize data class UserCancelled(val stage: String) : Result diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt index 8d146b37e41d..87db57eab030 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt @@ -18,6 +18,7 @@ package com.duckduckgo.autofill.impl.importing import android.net.Uri import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ImportResult import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope @@ -26,8 +27,13 @@ import javax.inject.Inject import kotlinx.coroutines.withContext interface CsvPasswordImporter { - suspend fun importCsv(fileUri: Uri): List - suspend fun importCsv(blob: String): List + suspend fun importCsv(fileUri: Uri): ImportResult + suspend fun importCsv(blob: String): ImportResult + + sealed interface ImportResult { + data class Success(val numberPasswordsInSource: Int, val passwordIdsImported: List) : ImportResult + data object Error : ImportResult + } } @ContributesBinding(AppScope::class) @@ -42,30 +48,31 @@ class GooglePasswordManagerCsvPasswordImporter @Inject constructor( private val blobDecoder: GooglePasswordBlobDecoder, ) : CsvPasswordImporter { - override suspend fun importCsv(blob: String): List { + override suspend fun importCsv(blob: String): ImportResult { return kotlin.runCatching { withContext(dispatchers.io()) { val csv = blobDecoder.decode(blob) importPasswords(csv) } - }.getOrElse { emptyList() } + }.getOrElse { ImportResult.Error } } - override suspend fun importCsv(fileUri: Uri): List { + override suspend fun importCsv(fileUri: Uri): ImportResult { return kotlin.runCatching { withContext(dispatchers.io()) { val csv = fileReader.readCsvFile(fileUri) importPasswords(csv) } - }.getOrElse { emptyList() } + }.getOrElse { ImportResult.Error } } - private suspend fun importPasswords(csv: String): List { + private suspend fun importPasswords(csv: String): ImportResult { val allPasswords = parser.parseCsv(csv) val dedupedPasswords = allPasswords.distinct() val validPasswords = filterValidPasswords(dedupedPasswords) val normalizedDomains = domainNameNormalizer.normalizeDomains(validPasswords) - return savePasswords(normalizedDomains) + val ids = savePasswords(normalizedDomains) + return ImportResult.Success(allPasswords.size, ids) } private suspend fun savePasswords(passwords: List): List { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordBlobConsumer.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordBlobConsumer.kt index a069c444f1df..0475bad015f8 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordBlobConsumer.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordBlobConsumer.kt @@ -24,7 +24,7 @@ import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.autofill.impl.importing.gpm.webflow.GooglePasswordBlobConsumer.Callback -import com.duckduckgo.browser.api.WebViewMessageListening +import com.duckduckgo.browser.api.download.WebViewBlobDownloader import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesBinding @@ -37,7 +37,7 @@ import okio.ByteString.Companion.encode interface GooglePasswordBlobConsumer { suspend fun configureWebViewForBlobDownload( webView: WebView, - callback: Callback + callback: Callback, ) suspend fun postMessageToConvertBlobToDataUri(url: String) @@ -50,23 +50,19 @@ interface GooglePasswordBlobConsumer { @ContributesBinding(FragmentScope::class) class ImportGooglePasswordBlobConsumer @Inject constructor( + private val webViewBlobDownloader: WebViewBlobDownloader, private val dispatchers: DispatcherProvider, - private val webViewMessageListening: WebViewMessageListening, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : GooglePasswordBlobConsumer { - private val replyProxyMap = mutableMapOf() - - // Map>() = Map>() - private val fixedReplyProxyMap = mutableMapOf>() - @SuppressLint("RequiresFeature") override suspend fun configureWebViewForBlobDownload( webView: WebView, - callback: Callback + callback: Callback, ) { withContext(dispatchers.main()) { - WebViewCompat.addDocumentStartJavaScript(webView, blobDownloadScript(), setOf("*")) + webViewBlobDownloader.addBlobDownloadSupport(webView) + WebViewCompat.addWebMessageListener( webView, "ddgBlobDownloadObj", @@ -93,108 +89,11 @@ class ImportGooglePasswordBlobConsumer @Inject constructor( }.onFailure { callback.onCsvError() } } else if (message.data?.startsWith("Ping:") == true) { val locationRef = message.data.toString().encode().md5().toString() - saveReplyProxyForBlobDownload(sourceOrigin.toString(), replyProxy, locationRef) + webViewBlobDownloader.storeReplyProxy(sourceOrigin.toString(), replyProxy, locationRef) } } - private suspend fun saveReplyProxyForBlobDownload( - originUrl: String, - replyProxy: JavaScriptReplyProxy, - locationHref: String? = null, - ) { - withContext(dispatchers.io()) { // FF check has disk IO - if (true) { - // if (webViewBlobDownloadFeature.fixBlobDownloadWithIframes().isEnabled()) { - val frameProxies = fixedReplyProxyMap[originUrl]?.toMutableMap() ?: mutableMapOf() - // if location.href is not passed, we fall back to origin - val safeLocationHref = locationHref ?: originUrl - frameProxies[safeLocationHref] = replyProxy - fixedReplyProxyMap[originUrl] = frameProxies - } else { - replyProxyMap[originUrl] = replyProxy - } - } - } - - @SuppressLint("RequiresFeature") // it's already checked in isBlobDownloadWebViewFeatureEnabled override suspend fun postMessageToConvertBlobToDataUri(url: String) { - withContext(dispatchers.main()) { // main because postMessage is not always safe in another thread - if (true) { - // if (withContext(dispatchers.io()) { webViewBlobDownloadFeature.fixBlobDownloadWithIframes().isEnabled() }) { - for ((key, proxies) in fixedReplyProxyMap) { - if (sameOrigin(url.removePrefix("blob:"), key)) { - for (replyProxy in proxies.values) { - replyProxy.postMessage(url) - } - return@withContext - } - } - } else { - for ((key, value) in replyProxyMap) { - if (sameOrigin(url.removePrefix("blob:"), key)) { - value.postMessage(url) - return@withContext - } - } - } - } - } - - private fun sameOrigin( - firstUrl: String, - secondUrl: String, - ): Boolean { - return kotlin.runCatching { - val firstUri = Uri.parse(firstUrl) - val secondUri = Uri.parse(secondUrl) - - firstUri.host == secondUri.host && firstUri.scheme == secondUri.scheme && firstUri.port == secondUri.port - }.getOrNull() ?: return false - } - - private fun blobDownloadScript(): String { - val script = """ - window.__url_to_blob_collection = {}; - - const original_createObjectURL = URL.createObjectURL; - - URL.createObjectURL = function () { - const blob = arguments[0]; - const url = original_createObjectURL.call(this, ...arguments); - if (blob instanceof Blob) { - __url_to_blob_collection[url] = blob; - } - return url; - } - - function blobToBase64DataUrl(blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = function() { - resolve(reader.result); - } - reader.onerror = function() { - reject(new Error('Failed to read Blob object')); - } - reader.readAsDataURL(blob); - }); - } - - const pingMessage = 'Ping:' + window.location.href - ddgBlobDownloadObj.postMessage(pingMessage) - - ddgBlobDownloadObj.onmessage = function(event) { - if (event.data.startsWith('blob:')) { - const blob = window.__url_to_blob_collection[event.data]; - if (blob) { - blobToBase64DataUrl(blob).then((dataUrl) => { - ddgBlobDownloadObj.postMessage(dataUrl); - }); - } - } - } - """.trimIndent() - - return script + webViewBlobDownloader.convertBlobToDataUri(url) } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt index f9efb2d9b2a9..da8fee7f0303 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt @@ -46,6 +46,8 @@ import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.databinding.FragmentImportGooglePasswordsWebflowBinding import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ImportResult +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ImportResult.Success import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.NavigatingBack import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.ShowingWebContent import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow @@ -289,10 +291,15 @@ class ImportGooglePasswordsWebFlowFragment : override suspend fun onCsvAvailable(csv: String) { Timber.i("cdr CSV available %s", csv) val result = csvPasswordImporter.importCsv(csv) - Timber.i("cdr Imported %d passwords", result.size) - val resultBundle = Bundle().also { - it.putParcelable(RESULT_KEY_DETAILS, Result.Success(result.size)) + val resultDetails = when (result) { + is Success -> { + Timber.i("cdr Found %d passwords; Imported %d passwords", result.numberPasswordsInSource, result.passwordIdsImported.size) + Result.Success(foundInImport = result.numberPasswordsInSource, importedCount = result.passwordIdsImported.size) + } + + ImportResult.Error -> Result.Error } + val resultBundle = Bundle().also { it.putParcelable(RESULT_KEY_DETAILS, resultDetails) } setFragmentResult(RESULT_KEY, resultBundle) } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt index 7abf2df16f66..d2837bf884bc 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt @@ -16,6 +16,7 @@ package com.duckduckgo.autofill.impl.importing.gpm.webflow +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordsFeature import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesBinding @@ -30,24 +31,34 @@ interface PasswordImporterScriptLoader { @ContributesBinding(FragmentScope::class) class PasswordImporterCssScriptLoader @Inject constructor( private val dispatchers: DispatcherProvider, + private val importPasswordsFeature: AutofillImportPasswordsFeature, ) : PasswordImporterScriptLoader { private lateinit var contentScopeJS: String override suspend fun getScript(): String { return withContext(dispatchers.io()) { + val javascriptConfig = buildConfig() getContentScopeJS() - .replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeJson()) + .replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeJson(javascriptConfig)) .replace(USER_UNPROTECTED_DOMAINS_PLACEHOLDER, getUnprotectedDomainsJson()) .replace(USER_PREFERENCES_PLACEHOLDER, getUserPreferencesJson()) } } - private fun getContentScopeJson( - showHintSignInButton: Boolean = true, - showHintSettingsButton: Boolean = true, - showHintExportButton: Boolean = true, - ): String = ( + private fun buildConfig(): JavascriptConfig { + val exportButton = ElementConfig(highlight = ElementConfigDetails(true)) + val settingsButton = ElementConfig(highlight = ElementConfigDetails(true)) + + return JavascriptConfig(signInButton = signInButtonConfig(), exportButton = exportButton, settingsButton = settingsButton) + } + + private fun signInButtonConfig(): ElementConfig { + return ElementConfig(highlight = ElementConfigDetails(importPasswordsFeature.canHighlightExportButton().isEnabled())) + } + + private fun getContentScopeJson(config: JavascriptConfig): String = ( + """{ "features":{ "passwordImport" : { @@ -56,32 +67,32 @@ class PasswordImporterCssScriptLoader @Inject constructor( "settings": { "settingsButton": { "highlight": { - "enabled": $showHintSettingsButton, - "selector": "bla bla" + "enabled": ${config.settingsButton.highlight.enabled}, + "selector": "${config.settingsButton.highlight.selector}" }, "autotap": { - "enabled": false, - "selector": "bla bla" + "enabled": ${config.settingsButton.clickAutomatically.enabled}, + "selector": "${config.settingsButton.clickAutomatically.selector}" } }, "exportButton": { "highlight": { - "enabled": $showHintExportButton, - "selector": "bla bla" + "enabled": ${config.exportButton.highlight.enabled}, + "selector": "${config.exportButton.highlight.selector}" }, "autotap": { - "enabled": false, - "selector": "bla bla" + "enabled": ${config.exportButton.clickAutomatically.enabled}, + "selector": "${config.exportButton.clickAutomatically.selector}" } }, "signInButton": { - "highlight":{ - "enabled": $showHintSignInButton, - "selector": "bla bla" + "highlight": { + "enabled": ${config.signInButton.highlight.enabled}, + "selector": "${config.signInButton.highlight.selector}" }, "autotap": { - "enabled": false, - "selector": "bla bla" + "enabled": ${config.signInButton.clickAutomatically.enabled}, + "selector": "${config.signInButton.clickAutomatically.selector}" } } } @@ -114,6 +125,22 @@ class PasswordImporterCssScriptLoader @Inject constructor( return contentScopeJS } + data class JavascriptConfig( + val signInButton: ElementConfig = ElementConfig(), + val exportButton: ElementConfig = ElementConfig(), + val settingsButton: ElementConfig = ElementConfig(), + ) + + data class ElementConfig( + val highlight: ElementConfigDetails = ElementConfigDetails(), + val clickAutomatically: ElementConfigDetails = ElementConfigDetails(), + ) + + data class ElementConfigDetails( + val enabled: Boolean = false, + val selector: String = "", + ) + companion object { private const val CONTENT_SCOPE_PLACEHOLDER = "\$CONTENT_SCOPE$" private const val USER_UNPROTECTED_DOMAINS_PLACEHOLDER = "\$USER_UNPROTECTED_DOMAINS$" diff --git a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt index 7110fc93a2e2..9212f50e7341 100644 --- a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt +++ b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt @@ -45,6 +45,7 @@ import com.duckduckgo.autofill.impl.configuration.AutofillJavascriptEnvironmentC import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore import com.duckduckgo.autofill.impl.engagement.store.AutofillEngagementRepository import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ImportResult import com.duckduckgo.autofill.impl.importing.CsvPasswordParser import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore @@ -132,8 +133,11 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { logcat { "cdr onActivityResult for CSV file request. resultCode=${result.resultCode}. uri=$fileUrl" } if (fileUrl != null) { lifecycleScope.launch { - val insertedIds = csvPasswordImporter.importCsv(fileUrl) - Toast.makeText(this@AutofillInternalSettingsActivity, "Imported ${insertedIds.size} passwords", Toast.LENGTH_LONG).show() + val message = when (val importResult = csvPasswordImporter.importCsv(fileUrl)) { + is ImportResult.Success -> "Imported ${importResult.passwordIdsImported.size} passwords" + ImportResult.Error -> "Failed to import passwords due to an error" + } + Toast.makeText(this@AutofillInternalSettingsActivity, message, Toast.LENGTH_LONG).show() } } } diff --git a/browser-api/build.gradle b/browser-api/build.gradle index dcb21130e7f7..51bff42a8a35 100644 --- a/browser-api/build.gradle +++ b/browser-api/build.gradle @@ -35,6 +35,8 @@ dependencies { // LiveData implementation AndroidX.lifecycle.liveDataKtx + + implementation AndroidX.webkit } android { namespace 'com.duckduckgo.browser.api' diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/download/WebViewBlobDownloader.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/download/WebViewBlobDownloader.kt new file mode 100644 index 000000000000..638c1893569e --- /dev/null +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/download/WebViewBlobDownloader.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 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.browser.api.download + +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy + +/** + * This interface provides the ability to add modern blob download support to a WebView. + */ +interface WebViewBlobDownloader { + + /** + * Configures a web view to support blob downloads, including in iframes. + */ + suspend fun addBlobDownloadSupport(webView: WebView) + + /** + * Requests the WebView to convert a blob URL to a data URI. + */ + suspend fun convertBlobToDataUri(blobUrl: String) + + /** + * Stores a reply proxy for a given location. + */ + suspend fun storeReplyProxy(originUrl: String, replyProxy: JavaScriptReplyProxy, locationHref: String?) + + /** + * Clears any stored JavaScript reply proxies. + */ + fun clearReplyProxies() +} diff --git a/package-lock.json b/package-lock.json index 5715926edca2..26217f52f6fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,13 +22,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", + "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/highlight": "^7.24.7", + "@babel/highlight": "^7.25.7", "picocolors": "^1.0.0" }, "engines": { @@ -36,9 +36,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", + "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", "dev": true, "license": "MIT", "engines": { @@ -46,13 +46,13 @@ } }, "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", + "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", + "@babel/helper-validator-identifier": "^7.25.7", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" @@ -76,7 +76,7 @@ "license": "Apache-2.0" }, "node_modules/@duckduckgo/content-scope-scripts": { - "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#c55305c796c0e5afcd902702d7d264620bf04993", + "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#a28a706947bb1d98e1c56896fac08de2f87bccad", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -711,18 +711,18 @@ } }, "node_modules/tldts-core": { - "version": "6.1.48", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.48.tgz", - "integrity": "sha512-3gD9iKn/n2UuFH1uilBviK9gvTNT6iYwdqrj1Vr5mh8FuelvpRNaYVH4pNYqUgOGU4aAdL9X35eLuuj0gRsx+A==", + "version": "6.1.50", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.50.tgz", + "integrity": "sha512-na2EcZqmdA2iV9zHV7OHQDxxdciEpxrjbkp+aHmZgnZKHzoElLajP59np5/4+sare9fQBfixgvXKx8ev1d7ytw==", "license": "MIT" }, "node_modules/tldts-experimental": { - "version": "6.1.48", - "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-6.1.48.tgz", - "integrity": "sha512-DfEGuLszDlllzx51WTABXB6LeMF46odcTWGUqG9rdTaRhiRlp+Ldkr1jiHugWBw/etwj71kr02rAUNt4cAet/w==", + "version": "6.1.50", + "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-6.1.50.tgz", + "integrity": "sha512-11HJNqCCbZb6g3CuEOGmFxqia8Nx7sT97IOo4nC3VArbjh6pvgE2+onemkxSbeDSZIcpNFobRGOOIo1J8DSHgQ==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.48" + "tldts-core": "^6.1.50" } }, "node_modules/undici-types": {