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 e4d2391eb06c..69d2009c25d7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -310,8 +310,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 @@ -321,7 +319,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 @@ -332,7 +329,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 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/build.gradle b/autofill/autofill-impl/build.gradle index d48819c3ccba..8ccbc43a69fc 100644 --- a/autofill/autofill-impl/build.gradle +++ b/autofill/autofill-impl/build.gradle @@ -18,6 +18,7 @@ plugins { id 'com.android.library' id 'kotlin-android' id 'com.squareup.anvil' + id 'kotlin-parcelize' } apply from: "$rootProject.projectDir/gradle/android-library.gradle" 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 bd611691db1d..2539628dd818 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 @@ -17,16 +17,28 @@ package com.duckduckgo.autofill.impl.importing import android.net.Uri +import android.os.Parcelable import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult.Success 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 +import kotlinx.parcelize.Parcelize interface CsvPasswordImporter { - suspend fun readCsv(blob: String): List - suspend fun readCsv(fileUri: Uri): List + suspend fun readCsv(blob: String): ParseResult + suspend fun readCsv(fileUri: Uri): ParseResult + + sealed interface ParseResult : Parcelable { + @Parcelize + data class Success(val numberPasswordsInSource: Int, val loginCredentialsToImport: List) : ParseResult + + @Parcelize + data object Error : ParseResult + } } @ContributesBinding(AppScope::class) @@ -39,30 +51,30 @@ class GooglePasswordManagerCsvPasswordImporter @Inject constructor( private val blobDecoder: GooglePasswordBlobDecoder, ) : CsvPasswordImporter { - override suspend fun readCsv(blob: String): List { + override suspend fun readCsv(blob: String): ParseResult { return kotlin.runCatching { withContext(dispatchers.io()) { val csv = blobDecoder.decode(blob) - importPasswords(csv) + convertToLoginCredentials(csv) } - }.getOrElse { emptyList() } + }.getOrElse { ParseResult.Error } } - override suspend fun readCsv(fileUri: Uri): List { + override suspend fun readCsv(fileUri: Uri): ParseResult { return kotlin.runCatching { withContext(dispatchers.io()) { val csv = fileReader.readCsvFile(fileUri) - importPasswords(csv) + convertToLoginCredentials(csv) } - }.getOrElse { emptyList() } + }.getOrElse { ParseResult.Error } } - private suspend fun importPasswords(csv: String): List { + private suspend fun convertToLoginCredentials(csv: String): Success { val allPasswords = parser.parseCsv(csv) val dedupedPasswords = allPasswords.distinct() val validPasswords = filterValidPasswords(dedupedPasswords) val normalizedDomains = domainNameNormalizer.normalizeDomains(validPasswords) - return normalizedDomains + return Success(allPasswords.size, normalizedDomains) } private fun filterValidPasswords(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 0dd3a250ae6d..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 @@ -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, ) { 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) - } - } - - 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 - } + webViewBlobDownloader.storeReplyProxy(sourceOrigin.toString(), replyProxy, locationRef) } } - @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 e6714f1316be..4a53c84d5984 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 @@ -38,7 +38,6 @@ import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin import com.duckduckgo.autofill.api.AutofillScreens.ImportGooglePassword.Result import com.duckduckgo.autofill.api.AutofillScreens.ImportGooglePassword.Result.Companion.RESULT_KEY import com.duckduckgo.autofill.api.AutofillScreens.ImportGooglePassword.Result.Companion.RESULT_KEY_DETAILS -import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory import com.duckduckgo.autofill.api.domain.app.LoginCredentials @@ -46,14 +45,15 @@ 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.PasswordImporter 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 import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowWebChromeClient.ProgressListener import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowWebViewClient.NewPageCallback -import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.ImportGooglePasswordAutofillCallback -import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.ImportGooglePasswordAutofillEventListener +import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillCallback +import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillEventListener +import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionInContextSignupFlowListener +import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionUserPromptListener import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.ConflatedJob @@ -72,8 +72,10 @@ class ImportGooglePasswordsWebFlowFragment : DuckDuckGoFragment(R.layout.fragment_import_google_passwords_webflow), ProgressListener, NewPageCallback, - ImportGooglePasswordAutofillEventListener, - ImportGooglePasswordAutofillCallback, + NoOpAutofillCallback, + NoOpEmailProtectionInContextSignupFlowListener, + NoOpEmailProtectionUserPromptListener, + NoOpAutofillEventListener, GooglePasswordBlobConsumer.Callback { @Inject @@ -110,7 +112,7 @@ class ImportGooglePasswordsWebFlowFragment : lateinit var csvPasswordImporter: CsvPasswordImporter @Inject - lateinit var passwordImporter: PasswordImporter + lateinit var browserAutofillConfigurator: BrowserAutofill.Configurator val viewModel by lazy { ViewModelProvider(requireActivity(), viewModelFactory)[ImportGooglePasswordsWebFlowViewModel::class.java] @@ -223,7 +225,13 @@ class ImportGooglePasswordsWebFlowFragment : private fun configureAutofill(it: WebView) { lifecycleScope.launch { - browserAutofill.addJsInterface(it, this@ImportGooglePasswordsWebFlowFragment, CUSTOM_FLOW_TAB_ID) + browserAutofill.addJsInterface( + it, + this@ImportGooglePasswordsWebFlowFragment, + this@ImportGooglePasswordsWebFlowFragment, + this@ImportGooglePasswordsWebFlowFragment, + CUSTOM_FLOW_TAB_ID, + ) } autofillFragmentResultListeners.getPlugins().forEach { plugin -> @@ -260,6 +268,7 @@ class ImportGooglePasswordsWebFlowFragment : private fun getToolbar() = (activity as ImportGooglePasswordsWebFlowActivity).binding.includeToolbar.toolbar override fun onPageStarted(url: String?) { + browserAutofillConfigurator.configureAutofillForCurrentPage(binding.webView, url) viewModel.onPageStarted(url) } @@ -268,20 +277,20 @@ class ImportGooglePasswordsWebFlowFragment : } override suspend fun onCredentialsAvailableToInject( - autofillWebMessageRequest: AutofillWebMessageRequest, + originalUrl: String, credentials: List, triggerType: LoginTriggerType, ) { Timber.i("cdr Credentials available to autofill (%d creds available)", credentials.size) withContext(dispatchers.main()) { val url = binding.webView.url ?: return@withContext - if (url != autofillWebMessageRequest.originalPageUrl) { + if (url != originalUrl) { Timber.w("WebView url has changed since autofill request; bailing") return@withContext } val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog( - autofillWebMessageRequest, + url, credentials, triggerType, CUSTOM_FLOW_TAB_ID, @@ -292,12 +301,24 @@ class ImportGooglePasswordsWebFlowFragment : override suspend fun onCsvAvailable(csv: String) { Timber.i("cdr CSV available %s", csv) - val passwords = csvPasswordImporter.readCsv(csv) - val result = passwordImporter.importPasswords(passwords) - Timber.i("cdr Imported %d passwords (# duplicates = %d", result.savedCredentialIds.size, result.duplicatedPasswords.size) - val resultBundle = Bundle().also { - it.putParcelable(RESULT_KEY_DETAILS, Result.Success(result.savedCredentialIds.size)) - } + val parseResult = csvPasswordImporter.readCsv(csv) + val resultBundle = Bundle().also { it.putParcelable(RESULT_KEY_DETAILS, parseResult) } + setFragmentResult(RESULT_KEY, resultBundle) + + /** + * val result = csvPasswordImporter.importCsv(csv) + * 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) } @@ -309,6 +330,25 @@ class ImportGooglePasswordsWebFlowFragment : setFragmentResult(RESULT_KEY, resultBundle) } + override fun onShareCredentialsForAutofill( + originalUrl: String, + selectedCredentials: LoginCredentials, + ) { + if (binding.webView.url != originalUrl) { + Timber.w("WebView url has changed since autofill request; bailing") + return + } + browserAutofill.injectCredentials(selectedCredentials) + } + + override fun onNoCredentialsChosenForAutofill(originalUrl: String) { + if (binding.webView.url != originalUrl) { + Timber.w("WebView url has changed since autofill request; bailing") + return + } + browserAutofill.injectCredentials(null) + } + companion object { private const val STARTING_URL = "https://passwords.google.com/options?ep=1" private const val CUSTOM_FLOW_TAB_ID = "import-passwords-webflow" 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..1d9b44f13778 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.selectors}"] }, "autotap": { - "enabled": false, - "selector": "bla bla" + "enabled": ${config.settingsButton.clickAutomatically.enabled}, + "selector": ["${config.settingsButton.clickAutomatically.selectors}"] } }, "exportButton": { "highlight": { - "enabled": $showHintExportButton, - "selector": "bla bla" + "enabled": ${config.exportButton.highlight.enabled}, + "selector": ["${config.exportButton.highlight.selectors}"] }, "autotap": { - "enabled": false, - "selector": "bla bla" + "enabled": ${config.exportButton.clickAutomatically.enabled}, + "selector": ["${config.exportButton.clickAutomatically.selectors}"] } }, "signInButton": { - "highlight":{ - "enabled": $showHintSignInButton, - "selector": "bla bla" + "highlight": { + "enabled": ${config.signInButton.highlight.enabled}, + "selector": ["${config.signInButton.highlight.selectors}"] }, "autotap": { - "enabled": false, - "selector": "bla bla" + "enabled": ${config.signInButton.clickAutomatically.enabled}, + "selector": ["${config.signInButton.clickAutomatically.selectors}"] } } } @@ -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 selectors: List = listOf(""), + ) + 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-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt index 7339119699f2..8c9a35f141f7 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt @@ -17,28 +17,93 @@ package com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill import com.duckduckgo.autofill.api.AutofillEventListener -import com.duckduckgo.autofill.api.AutofillWebMessageRequest import com.duckduckgo.autofill.api.Callback +import com.duckduckgo.autofill.api.EmailProtectionInContextSignupFlowListener +import com.duckduckgo.autofill.api.EmailProtectionUserPromptListener import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType -interface ImportGooglePasswordAutofillCallback : Callback { +interface NoOpAutofillCallback : Callback { + override suspend fun onCredentialsAvailableToInject( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + ) { + } + + override suspend fun onCredentialsAvailableToSave( + currentUrl: String, + credentials: LoginCredentials, + ) { + } override suspend fun onGeneratedPasswordAvailableToUse( - autofillWebMessageRequest: AutofillWebMessageRequest, + originalUrl: String, username: String?, generatedPassword: String, - ) {} + ) { + } + + override fun noCredentialsAvailable(originalUrl: String) { + } + + override fun onCredentialsSaved(savedCredentials: LoginCredentials) { + } +} + +interface NoOpAutofillEventListener : AutofillEventListener { + override fun onAcceptGeneratedPassword(originalUrl: String) { + } + + override fun onRejectGeneratedPassword(originalUrl: String) { + } + + override fun onUseEmailProtectionPersonalAddress( + originalUrl: String, + duckAddress: String, + ) { + } + + override fun onUseEmailProtectionPrivateAlias( + originalUrl: String, + duckAddress: String, + ) { + } - override fun onCredentialsSaved(savedCredentials: LoginCredentials) {} + override fun onSelectedToSignUpForInContextEmailProtection() { + } - override fun showNativeChooseEmailAddressPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) {} - override suspend fun onCredentialsAvailableToSave(autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials) {} - override fun showNativeInContextEmailProtectionSignupPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) {} + override fun onEndOfEmailProtectionInContextSignupFlow() { + } + + override fun onShareCredentialsForAutofill( + originalUrl: String, + selectedCredentials: LoginCredentials, + ) { + } + + override fun onNoCredentialsChosenForAutofill(originalUrl: String) { + } + + override fun onSavedCredentials(credentials: LoginCredentials) { + } + + override fun onUpdatedCredentials(credentials: LoginCredentials) { + } + + override fun onAutofillStateChange() { + } +} + +interface NoOpEmailProtectionInContextSignupFlowListener : EmailProtectionInContextSignupFlowListener { + override fun closeInContextSignup() { + } } -interface ImportGooglePasswordAutofillEventListener : AutofillEventListener { - override fun onSelectedToSignUpForInContextEmailProtection(autofillWebMessageRequest: AutofillWebMessageRequest) {} - override fun onSavedCredentials(credentials: LoginCredentials) {} - override fun onUpdatedCredentials(credentials: LoginCredentials) {} - override fun onAutofillStateChange() {} +interface NoOpEmailProtectionUserPromptListener : EmailProtectionUserPromptListener { + override fun showNativeInContextEmailProtectionSignupPrompt() { + } + + override fun showNativeChooseEmailAddressPrompt() { + } } 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 eb28aeece635..fb841bf73b0c 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.ParseResult import com.duckduckgo.autofill.impl.importing.PasswordImporter import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore @@ -132,13 +133,17 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { logcat { "cdr onActivityResult for CSV file request. resultCode=${result.resultCode}. uri=$fileUrl" } if (fileUrl != null) { lifecycleScope.launch(dispatchers.io()) { - val passwords = csvPasswordImporter.readCsv(fileUrl) - val importResult = passwordImporter.importPasswords(passwords) - Toast.makeText( - this@AutofillInternalSettingsActivity, - "Imported ${importResult.savedCredentialIds.size} passwords", - Toast.LENGTH_LONG, - ).show() + val message = when (val parseResult = csvPasswordImporter.readCsv(fileUrl)) { + is ParseResult.Success -> { + val importResult = passwordImporter.importPasswords(parseResult.loginCredentialsToImport) + "Imported ${importResult.savedCredentialIds.size} passwords" + } + is ParseResult.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 f2201a84cdb3..95b21d0286fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,7 @@ "hasInstallScript": true }, "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, "workspaces": [ "packages/special-pages",