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/WebViewSafeMessageListening.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewSafeMessageListening.kt deleted file mode 100644 index d614b3167088..000000000000 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewSafeMessageListening.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 - -import androidx.webkit.WebViewFeature -import com.duckduckgo.browser.api.WebViewMessageListening -import com.duckduckgo.browser.api.WebViewVersionProvider -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.common.utils.extensions.compareSemanticVersion -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject -import kotlinx.coroutines.withContext - -@ContributesBinding(AppScope::class) -class WebViewSafeMessageListening @Inject constructor( - private val dispatchers: DispatcherProvider, - private val webViewVersionProvider: WebViewVersionProvider, -) : WebViewMessageListening { - - override suspend fun isWebMessageListenerSupported(): Boolean { - return withContext(dispatchers.io()) { - webViewVersionProvider.getFullVersion().compareSemanticVersion(WEB_MESSAGE_LISTENER_WEBVIEW_VERSION)?.let { - it >= 0 - } ?: false - } && WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) - } - - companion object { - private const val WEB_MESSAGE_LISTENER_WEBVIEW_VERSION = "126.0.6478.40" - } -} 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..b600554a3f76 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 @@ -16,10 +16,8 @@ package com.duckduckgo.autofill.api -import android.os.Parcelable import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams -import kotlinx.parcelize.Parcelize sealed interface AutofillScreens { @@ -50,29 +48,6 @@ sealed interface AutofillScreens { val loginCredentials: LoginCredentials, val source: AutofillSettingsLaunchSource, ) : ActivityParams - - object ImportGooglePassword { - data object AutofillImportViaGooglePasswordManagerScreen : ActivityParams { - private fun readResolve(): Any = AutofillImportViaGooglePasswordManagerScreen - } - - sealed interface Result : Parcelable { - - companion object { - const val RESULT_KEY = "importResult" - const val RESULT_KEY_DETAILS = "importResultDetails" - } - - @Parcelize - data class Success(val importedCount: Int) : Result - - @Parcelize - data class UserCancelled(val stage: String) : Result - - @Parcelize - data object Error : Result - } - } } enum class AutofillSettingsLaunchSource { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt index 3ec09c52ba50..04a11c36fcd0 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt @@ -34,7 +34,10 @@ interface CsvCredentialConverter { sealed interface CsvCredentialImportResult : Parcelable { @Parcelize - data class Success(val numberCredentialsInSource: Int, val loginCredentialsToImport: List) : CsvCredentialImportResult + data class Success( + val numberCredentialsInSource: Int, + val loginCredentialsToImport: List, + ) : CsvCredentialImportResult @Parcelize data object Error : CsvCredentialImportResult @@ -80,15 +83,27 @@ class GooglePasswordManagerCsvCredentialConverter @Inject constructor( } } - private suspend fun deduplicateAndCleanup(allCredentials: List): List { - val dedupedCredentials = allCredentials.distinct() - val validCredentials = dedupedCredentials.filter { credentialValidator.isValid(it) } - val normalizedDomains = domainNameNormalizer.normalizeDomains(validCredentials) - val entriesNotAlreadySaved = filterNewCredentials(normalizedDomains) - return entriesNotAlreadySaved + private suspend fun deduplicateAndCleanup(allCredentials: List): List { + return allCredentials + .distinct() + .filter { credentialValidator.isValid(it) } + .toLoginCredentials() + .filterNewCredentials() } - private suspend fun filterNewCredentials(credentials: List): List { - return existingCredentialMatchDetector.filterExistingCredentials(credentials) + private suspend fun List.toLoginCredentials(): List { + return this.map { + LoginCredentials( + domainTitle = it.title, + username = it.username, + password = it.password, + domain = domainNameNormalizer.normalize(it.url), + notes = it.notes, + ) + } + } + + private suspend fun List.filterNewCredentials(): List { + return existingCredentialMatchDetector.filterExistingCredentials(this) } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt index a84020b06cef..17f5037db84d 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt @@ -16,7 +16,6 @@ package com.duckduckgo.autofill.impl.importing -import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Error import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Success @@ -33,7 +32,7 @@ interface CsvCredentialParser { suspend fun parseCsv(csv: String): ParseResult sealed interface ParseResult { - data class Success(val credentials: List) : ParseResult + data class Success(val credentials: List) : ParseResult data object Error : ParseResult } } @@ -61,7 +60,7 @@ class GooglePasswordManagerCsvCredentialParser @Inject constructor( * Format of the Google Password Manager CSV is: * name | url | username | password | note */ - private suspend fun convertToCredentials(csv: String): List { + private suspend fun convertToCredentials(csv: String): List { return withContext(dispatchers.io()) { val lines = mutableListOf() val iter = CsvReader.builder().build(csv).spliterator() @@ -81,8 +80,8 @@ class GooglePasswordManagerCsvCredentialParser @Inject constructor( } parseToCredential( - domainTitle = it.getField(0).blanksToNull(), - domain = it.getField(1).blanksToNull(), + title = it.getField(0).blanksToNull(), + url = it.getField(1).blanksToNull(), username = it.getField(2).blanksToNull(), password = it.getField(3).blanksToNull(), notes = it.getField(4).blanksToNull(), @@ -92,15 +91,15 @@ class GooglePasswordManagerCsvCredentialParser @Inject constructor( } private fun parseToCredential( - domainTitle: String?, - domain: String?, + title: String?, + url: String?, username: String?, password: String?, notes: String?, - ): LoginCredentials { - return LoginCredentials( - domainTitle = domainTitle, - domain = domain, + ): GoogleCsvLoginCredential { + return GoogleCsvLoginCredential( + title = title, + url = url, username = username, password = password, notes = notes, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt index 86fc4fd60a03..06d41a57efd8 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt @@ -16,25 +16,24 @@ package com.duckduckgo.autofill.impl.importing -import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject interface DomainNameNormalizer { - suspend fun normalizeDomains(unnormalized: List): List + suspend fun normalize(unnormalizedUrl: String?): String? } @ContributesBinding(AppScope::class) class DefaultDomainNameNormalizer @Inject constructor( private val urlMatcher: AutofillUrlMatcher, ) : DomainNameNormalizer { - override suspend fun normalizeDomains(unnormalized: List): List { - return unnormalized.map { - val currentDomain = it.domain ?: return@map it - val normalizedDomain = urlMatcher.cleanRawUrl(currentDomain) - it.copy(domain = normalizedDomain) + override suspend fun normalize(unnormalizedUrl: String?): String? { + return if (unnormalizedUrl == null) { + null + } else { + urlMatcher.cleanRawUrl(unnormalizedUrl) } } } diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/WebViewMessageListening.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt similarity index 64% rename from browser-api/src/main/java/com/duckduckgo/browser/api/WebViewMessageListening.kt rename to autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt index b612dea72384..e7386b06504d 100644 --- a/browser-api/src/main/java/com/duckduckgo/browser/api/WebViewMessageListening.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt @@ -14,9 +14,15 @@ * limitations under the License. */ -package com.duckduckgo.browser.api +package com.duckduckgo.autofill.impl.importing -interface WebViewMessageListening { - - suspend fun isWebMessageListenerSupported(): Boolean -} +/** + * Data class representing the login credentials imported from a Google CSV file. + */ +data class GoogleCsvLoginCredential( + val url: String? = null, + val username: String? = null, + val password: String? = null, + val title: String? = null, + val notes: String? = null, +) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt index 5677cda8d040..064cee3df1a2 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt @@ -16,21 +16,22 @@ package com.duckduckgo.autofill.impl.importing -import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject interface ImportedCredentialValidator { - fun isValid(loginCredentials: LoginCredentials): Boolean + fun isValid(loginCredentials: GoogleCsvLoginCredential): Boolean } @ContributesBinding(AppScope::class) class DefaultImportedCredentialValidator @Inject constructor() : ImportedCredentialValidator { - override fun isValid(loginCredentials: LoginCredentials): Boolean { + override fun isValid(loginCredentials: GoogleCsvLoginCredential): Boolean { with(loginCredentials) { - if (domain?.startsWith(APP_PASSWORD_PREFIX) == true) return false + if (url?.startsWith(APP_PASSWORD_PREFIX) == true) { + return false + } if (allFieldsEmpty()) { return false @@ -40,11 +41,11 @@ class DefaultImportedCredentialValidator @Inject constructor() : ImportedCredent } } - private fun LoginCredentials.allFieldsEmpty(): Boolean { - return domain.isNullOrBlank() && + private fun GoogleCsvLoginCredential.allFieldsEmpty(): Boolean { + return url.isNullOrBlank() && username.isNullOrBlank() && password.isNullOrBlank() && - domainTitle.isNullOrBlank() && + title.isNullOrBlank() && notes.isNullOrBlank() } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/WebViewBlobDownloader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/WebViewBlobDownloader.kt new file mode 100644 index 000000000000..07cbe2f750b4 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/WebViewBlobDownloader.kt @@ -0,0 +1,163 @@ +/* + * 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.autofill.impl.importing.blob + +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.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +/** + * 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() +} + +@ContributesBinding(FragmentScope::class) +class WebViewBlobDownloaderModernImpl @Inject constructor( + private val webViewCapabilityChecker: WebViewCapabilityChecker, +) : WebViewBlobDownloader { + + // 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) { + for ((key, proxies) in fixedReplyProxyMap) { + if (sameOrigin(blobUrl.removePrefix("blob:"), key)) { + for (replyProxy in proxies.values) { + replyProxy.postMessage(blobUrl) + } + return + } + } + } + + override suspend fun storeReplyProxy( + originUrl: String, + replyProxy: JavaScriptReplyProxy, + locationHref: String?, + ) { + 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 + } + + 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() + } + + private suspend fun isBlobDownloadWebViewFeatureEnabled(): Boolean { + return 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-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..870b7c0fa93c 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 @@ -23,8 +23,8 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.impl.importing.blob.WebViewBlobDownloader import com.duckduckgo.autofill.impl.importing.gpm.webflow.GooglePasswordBlobConsumer.Callback -import com.duckduckgo.browser.api.WebViewMessageListening import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesBinding @@ -50,23 +50,20 @@ 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") + // access to the flow which uses this be guarded against where these features aren't available + @SuppressLint("RequiresFeature", "AddWebMessageListenerUsage") 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 +90,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/ImportGooglePasswordResult.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt new file mode 100644 index 000000000000..a8faff9a2eaa --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt @@ -0,0 +1,37 @@ +/* + * 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.autofill.impl.importing.gpm.webflow + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed interface ImportGooglePasswordResult : Parcelable { + + @Parcelize + data object Success : ImportGooglePasswordResult + + @Parcelize + data class UserCancelled(val stage: String) : ImportGooglePasswordResult + + @Parcelize + data object Error : ImportGooglePasswordResult + + companion object { + const val RESULT_KEY = "importResult" + const val RESULT_KEY_DETAILS = "importResultDetails" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt index a5ca8ee8a6cb..97838bcddec1 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt @@ -21,15 +21,16 @@ import android.os.Bundle import androidx.fragment.app.commit import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.autofill.api.AutofillScreens.ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen -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.AutofillScreens.ImportGooglePassword.Result.UserCancelled import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.databinding.ActivityImportGooglePasswordsWebflowBinding +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY_DETAILS +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.UserCancelled import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams @InjectWith(ActivityScope::class) @ContributeToActivityStarter(AutofillImportViaGooglePasswordManagerScreen::class) @@ -68,3 +69,9 @@ class ImportGooglePasswordsWebFlowActivity : DuckDuckGoActivity() { exitWithResult(result) } } + +object ImportGooglePassword { + data object AutofillImportViaGooglePasswordManagerScreen : ActivityParams { + private fun readResolve(): Any = AutofillImportViaGooglePasswordManagerScreen + } +} 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..1567f2898930 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 @@ -17,9 +17,10 @@ package com.duckduckgo.autofill.impl.importing.gpm.webflow import android.annotation.SuppressLint -import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.webkit.WebSettings import android.webkit.WebView import androidx.activity.OnBackPressedCallback @@ -35,27 +36,21 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autofill.api.AutofillCapabilityChecker 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 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.ImportGooglePasswordResult.Companion.RESULT_KEY +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY_DETAILS +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.* 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 import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.FragmentViewModelFactory @@ -70,10 +65,11 @@ import timber.log.Timber @InjectWith(FragmentScope::class) class ImportGooglePasswordsWebFlowFragment : DuckDuckGoFragment(R.layout.fragment_import_google_passwords_webflow), - ProgressListener, NewPageCallback, - ImportGooglePasswordAutofillEventListener, - ImportGooglePasswordAutofillCallback, + NoOpAutofillCallback, + NoOpEmailProtectionInContextSignupFlowListener, + NoOpEmailProtectionUserPromptListener, + NoOpAutofillEventListener, GooglePasswordBlobConsumer.Callback { @Inject @@ -107,18 +103,24 @@ class ImportGooglePasswordsWebFlowFragment : lateinit var passwordImporterScriptLoader: PasswordImporterScriptLoader @Inject - lateinit var csvPasswordImporter: CsvPasswordImporter + lateinit var browserAutofillConfigurator: BrowserAutofill.Configurator - @Inject - lateinit var passwordImporter: PasswordImporter + private val autofillConfigurationJob = ConflatedJob() + + private var binding: FragmentImportGooglePasswordsWebflowBinding? = null - val viewModel by lazy { + private val viewModel by lazy { ViewModelProvider(requireActivity(), viewModelFactory)[ImportGooglePasswordsWebFlowViewModel::class.java] } - private val autofillConfigurationJob = ConflatedJob() - - private val binding: FragmentImportGooglePasswordsWebflowBinding by viewBinding() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + binding = FragmentImportGooglePasswordsWebflowBinding.inflate(inflater, container, false) + return binding?.root + } override fun onViewCreated( view: View, @@ -129,16 +131,19 @@ class ImportGooglePasswordsWebFlowFragment : configureWebView() configureBackButtonHandler() observeViewState() - loadFirstWebpage(activity?.intent) + viewModel.onViewCreated() } - private fun loadFirstWebpage(intent: Intent?) { + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun loadFirstWebpage(url: String) { lifecycleScope.launch(dispatchers.main()) { autofillConfigurationJob.join() - - binding.webView.loadUrl(STARTING_URL) - - viewModel.loadedStartingUrl() + binding?.webView?.loadUrl(url) + viewModel.firstPageLoading() } } @@ -147,14 +152,14 @@ class ImportGooglePasswordsWebFlowFragment : repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.viewState.collect { viewState -> when (viewState) { - // is ViewState.CancellingInContextSignUp -> cancelInContextSignUp() - // is ViewState.ConfirmingCancellationOfInContextSignUp -> confirmCancellationOfInContextSignUp() - // is ViewState.NavigatingBack -> navigateWebViewBack() - // is ViewState.ShowingWebContent -> showWebContent(viewState) - // is ViewState.ExitingAsSuccess -> closeActivityAsSuccessfulSignup() - is ShowingWebContent -> {} // TODO() + is UserFinishedImportFlow -> exitFlowAsSuccess() is UserCancelledImportFlow -> exitFlowAsCancellation(viewState.stage) - is NavigatingBack -> binding.webView.goBack() + is UserFinishedCannotImport -> exitFlowAsImpossibleToImport() + is NavigatingBack -> binding?.webView?.goBack() + is LoadStartPage -> loadFirstWebpage(viewState.initialLaunchUrl) + is WebContentShowing, Initializing -> { + // no-op + } } } } @@ -165,13 +170,27 @@ class ImportGooglePasswordsWebFlowFragment : (activity as ImportGooglePasswordsWebFlowActivity).exitUserCancelled(stage) } + private fun exitFlowAsSuccess() { + val resultBundle = Bundle().also { + it.putParcelable(RESULT_KEY_DETAILS, ImportGooglePasswordResult.Success) + } + setFragmentResult(RESULT_KEY, resultBundle) + } + + private fun exitFlowAsImpossibleToImport() { + val resultBundle = Bundle().also { + it.putParcelable(RESULT_KEY_DETAILS, ImportGooglePasswordResult.Error) + } + setFragmentResult(RESULT_KEY, resultBundle) + } + private fun configureBackButtonHandler() { activity?.let { it.onBackPressedDispatcher.addCallback( it, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - viewModel.onBackButtonPressed(url = binding.webView.url, canGoBack = binding.webView.canGoBack()) + viewModel.onBackButtonPressed(url = binding?.webView?.url, canGoBack = binding?.webView?.canGoBack() ?: false) } }, ) @@ -182,7 +201,7 @@ class ImportGooglePasswordsWebFlowFragment : with(getToolbar()) { title = getString(R.string.autofillImportGooglePasswordsWebFlowTitle) setNavigationIconAsCross() - setNavigationOnClickListener { viewModel.onCloseButtonPressed(binding.webView.url) } + setNavigationOnClickListener { viewModel.onCloseButtonPressed(binding?.webView?.url) } } } @@ -190,11 +209,9 @@ class ImportGooglePasswordsWebFlowFragment : setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_close_24) } - @SuppressLint("SetJavaScriptEnabled", "RequiresFeature") + @SuppressLint("SetJavaScriptEnabled") private fun configureWebView() { - Timber.i("cdr Configuring WebView") - binding.webView.let { webView -> - webView.webChromeClient = ImportGooglePasswordsWebFlowWebChromeClient(this) + binding?.webView?.let { webView -> webView.webViewClient = ImportGooglePasswordsWebFlowWebViewClient(this) webView.settings.apply { @@ -223,7 +240,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 -> @@ -242,9 +265,8 @@ class ImportGooglePasswordsWebFlowFragment : } private fun configureDownloadInterceptor(it: WebView) { - it.setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength -> + it.setDownloadListener { url, _, _, _, _ -> if (url.startsWith("blob:")) { - // if (isBlobDownloadWebViewFeatureEnabled) { lifecycleScope.launch { passwordBlobConsumer.postMessageToConvertBlobToDataUri(url) } @@ -252,6 +274,7 @@ class ImportGooglePasswordsWebFlowFragment : } } + @SuppressLint("RequiresFeature") private suspend fun configurePasswordImportJavascript(webView: WebView) { val script = passwordImporterScriptLoader.getScript() WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) @@ -260,28 +283,25 @@ class ImportGooglePasswordsWebFlowFragment : private fun getToolbar() = (activity as ImportGooglePasswordsWebFlowActivity).binding.includeToolbar.toolbar override fun onPageStarted(url: String?) { - viewModel.onPageStarted(url) - } - - override fun onPageFinished(url: String?) { - viewModel.onPageFinished(url) + binding?.let { + browserAutofillConfigurator.configureAutofillForCurrentPage(it.webView, url) + } } 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) { + val url = binding?.webView?.url ?: return@withContext + 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, @@ -291,26 +311,33 @@ 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)) - } - setFragmentResult(RESULT_KEY, resultBundle) + viewModel.onCsvAvailable(csv) } override suspend fun onCsvError() { - Timber.e("cdr Error decoding CSV") - val resultBundle = Bundle().also { - it.putParcelable(RESULT_KEY_DETAILS, Result.Error) + viewModel.onCsvError() + } + + override fun onShareCredentialsForAutofill( + originalUrl: String, + selectedCredentials: LoginCredentials, + ) { + if (binding?.webView?.url != originalUrl) { + Timber.w("WebView url has changed since autofill request; bailing") + return } - setFragmentResult(RESULT_KEY, resultBundle) + 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" private const val SELECT_CREDENTIALS_FRAGMENT_TAG = "autofillSelectCredentialsDialog" } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt index bd6adeb7b634..125d39083c16 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt @@ -17,46 +17,67 @@ package com.duckduckgo.autofill.impl.importing.gpm.webflow import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel -import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.ShowingWebContent +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.Initializing import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import timber.log.Timber @ContributesViewModel(ActivityScope::class) class ImportGooglePasswordsWebFlowViewModel @Inject constructor( - private val pixel: Pixel, + private val dispatchers: DispatcherProvider, + private val credentialImporter: CredentialImporter, + private val csvCredentialConverter: CsvCredentialConverter, + private val autofillImportConfigStore: AutofillImportPasswordConfigStore, ) : ViewModel() { - @Inject - lateinit var emailManager: EmailManager + private val _viewState = MutableStateFlow(Initializing) + val viewState: StateFlow = _viewState - @Inject - lateinit var dispatchers: DispatcherProvider + fun onViewCreated() { + viewModelScope.launch(dispatchers.io()) { + _viewState.value = ViewState.LoadStartPage(autofillImportConfigStore.getConfig().launchUrlGooglePasswords) + } + } - private val _viewState = MutableStateFlow(ShowingWebContent) - val viewState: StateFlow = _viewState + suspend fun onCsvAvailable(csv: String) { + when (val parseResult = csvCredentialConverter.readCsv(csv)) { + is CsvCredentialImportResult.Success -> onCsvParsed(parseResult) + is CsvCredentialImportResult.Error -> onCsvError() + } + } - fun onPageStarted(url: String?) { - Timber.i("onPageStarted: $url") + private suspend fun onCsvParsed(parseResult: CsvCredentialImportResult.Success) { + credentialImporter.import(parseResult.loginCredentialsToImport, parseResult.numberCredentialsInSource) + _viewState.value = ViewState.UserFinishedImportFlow } - fun onPageFinished(url: String?) { - _viewState.value = ShowingWebContent - Timber.i("onPageFinished: $url") + fun onCsvError() { + Timber.w("Error decoding CSV") + _viewState.value = ViewState.UserFinishedCannotImport + } + + fun onCloseButtonPressed(url: String?) { + if (url?.startsWith(ENCRYPTED_PASSPHRASE_ERROR_URL) == true) { + _viewState.value = ViewState.UserFinishedCannotImport + } else { + terminateFlowAsCancellation(url ?: "unknown") + } } fun onBackButtonPressed( url: String?, canGoBack: Boolean, ) { - Timber.v("onBackButtonPressed: %s, canGoBack=%s", url, canGoBack) - // if WebView can't go back, then we're at the first stage or something's gone wrong. Either way, time to cancel out of the screen. if (!canGoBack) { terminateFlowAsCancellation(url ?: "unknown") @@ -70,17 +91,17 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( _viewState.value = ViewState.UserCancelledImportFlow(stage) } - fun loadedStartingUrl() { - // pixel.fire(EMAIL_PROTECTION_IN_CONTEXT_MODAL_DISPLAYED) - } - - fun onCloseButtonPressed(url: String?) { - terminateFlowAsCancellation(url ?: "unknown") + fun firstPageLoading() { + _viewState.value = ViewState.WebContentShowing } sealed interface ViewState { - data object ShowingWebContent : ViewState + data object Initializing : ViewState + data object WebContentShowing : ViewState + data class LoadStartPage(val initialLaunchUrl: String) : ViewState data class UserCancelledImportFlow(val stage: String) : ViewState + data object UserFinishedImportFlow : ViewState + data object UserFinishedCannotImport : ViewState data object NavigatingBack : ViewState } @@ -89,5 +110,6 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( } companion object { + const val ENCRYPTED_PASSPHRASE_ERROR_URL = "https://passwords.google.com/error/sync-passphrase" } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebChromeClient.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebChromeClient.kt deleted file mode 100644 index a39ee4242532..000000000000 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebChromeClient.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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.autofill.impl.importing.gpm.webflow - -import android.webkit.WebChromeClient -import android.webkit.WebView -import timber.log.Timber - -class ImportGooglePasswordsWebFlowWebChromeClient( - private val callback: ProgressListener, -) : WebChromeClient() { - - interface ProgressListener { - // fun onPageFinished(url: String) - } - - override fun onProgressChanged( - webView: WebView?, - newProgress: Int, - ) { - val url = webView?.url ?: return - Timber.i("onProgressChanged: $newProgress $url") - // when (newProgress) { - // PROGRESS_PAGE_FINISH -> callback.onPageFinished(url) - // } - } - - companion object { - private const val PROGRESS_PAGE_FINISH = 100 - } -} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt index e183a3673b89..98d558a4d3a1 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt @@ -26,8 +26,8 @@ class ImportGooglePasswordsWebFlowWebViewClient @Inject constructor( ) : WebViewClient() { interface NewPageCallback { - fun onPageStarted(url: String?) - fun onPageFinished(url: String?) + fun onPageStarted(url: String?) {} + fun onPageFinished(url: String?) {} } override fun onPageStarted( 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..be04d3ffa89e 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.AutofillImportPasswordConfigStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.FragmentScope import com.squareup.anvil.annotations.ContributesBinding @@ -30,6 +31,7 @@ interface PasswordImporterScriptLoader { @ContributesBinding(FragmentScope::class) class PasswordImporterCssScriptLoader @Inject constructor( private val dispatchers: DispatcherProvider, + private val configStore: AutofillImportPasswordConfigStore, ) : PasswordImporterScriptLoader { private lateinit var contentScopeJS: String @@ -37,61 +39,34 @@ class PasswordImporterCssScriptLoader @Inject constructor( override suspend fun getScript(): String { return withContext(dispatchers.io()) { getContentScopeJS() - .replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeJson()) + .replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeJson(loadSettingsJson())) .replace(USER_UNPROTECTED_DOMAINS_PLACEHOLDER, getUnprotectedDomainsJson()) .replace(USER_PREFERENCES_PLACEHOLDER, getUserPreferencesJson()) } } - private fun getContentScopeJson( - showHintSignInButton: Boolean = true, - showHintSettingsButton: Boolean = true, - showHintExportButton: Boolean = true, - ): String = ( - """{ + /** + * This enables the password import hints feature in C-S-S. + * These settings are for enabling it; the check for whether it should be enabled or not is done elsewhere. + */ + private fun getContentScopeJson(settingsJson: String): String { + return """{ "features":{ - "passwordImport" : { + "autofillPasswordImport" : { "state": "enabled", "exceptions": [], - "settings": { - "settingsButton": { - "highlight": { - "enabled": $showHintSettingsButton, - "selector": "bla bla" - }, - "autotap": { - "enabled": false, - "selector": "bla bla" - } - }, - "exportButton": { - "highlight": { - "enabled": $showHintExportButton, - "selector": "bla bla" - }, - "autotap": { - "enabled": false, - "selector": "bla bla" - } - }, - "signInButton": { - "highlight":{ - "enabled": $showHintSignInButton, - "selector": "bla bla" - }, - "autotap": { - "enabled": false, - "selector": "bla bla" - } - } - } + "settings": $settingsJson } }, "unprotectedTemporary":[] } """.trimMargin() - ) + } + + private suspend fun loadSettingsJson(): String { + return configStore.getConfig().javascriptConfigGooglePasswords + } private fun getUserPreferencesJson(): String { return """ @@ -109,7 +84,7 @@ class PasswordImporterCssScriptLoader @Inject constructor( private fun getContentScopeJS(): String { if (!this::contentScopeJS.isInitialized) { - contentScopeJS = loadJs("passwordImport.js") + contentScopeJS = loadJs("autofillPasswordImport.js") } return contentScopeJS } 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-impl/src/main/res/values/donottranslate.xml b/autofill/autofill-impl/src/main/res/values/donottranslate.xml index eff5d24dd1e2..fa2047cdba76 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -19,5 +19,5 @@ Passwords Import Google Passwords - %1$d passwords imported from Google + %1$d passwords imported from Google \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt index 7ef23f71300f..4a7bd3be0640 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt @@ -1,12 +1,10 @@ package com.duckduckgo.autofill.impl.importing import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.encoding.UrlUnicodeNormalizerImpl import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -16,56 +14,32 @@ class DefaultDomainNameNormalizerTest { private val testee = DefaultDomainNameNormalizer(AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl())) @Test - fun whenEmptyInputThenEmptyOutput() = runTest { - val input = emptyList() - val output = testee.normalizeDomains(input) - assertTrue(output.isEmpty()) + fun whenInputIsEmptyStringThenEmptyOutput() = runTest { + val output = testee.normalize("") + assertEquals("", output) } @Test fun whenInputDomainAlreadyNormalizedThenIncludedInOutput() = runTest { - val input = listOf(creds(domain = "example.com")) - val output = testee.normalizeDomains(input) - assertEquals(1, output.size) - assertEquals(input.first(), output.first()) + val output = testee.normalize("example.com") + assertEquals("example.com", output) } @Test fun whenInputDomainNotAlreadyNormalizedThenNormalizedAndIncludedInOutput() = runTest { - val input = listOf(creds(domain = "https://example.com/foo/bar")) - val output = testee.normalizeDomains(input) - assertEquals(1, output.size) - assertEquals(input.first().copy(domain = "example.com"), output.first()) + val output = testee.normalize("https://example.com/foo/bar") + assertEquals("example.com", output) } @Test fun whenInputDomainIsNullThenNormalizedToNullDomain() = runTest { - val input = listOf(creds(domain = null)) - val output = testee.normalizeDomains(input) - assertEquals(1, output.size) - assertEquals(null, output.first().domain) + val output = testee.normalize(null) + assertEquals(null, output) } @Test fun whenDomainCannotBeNormalizedThenIsIncludedUnmodified() = runTest { - val input = listOf(creds(domain = "unnormalizable")) - val output = testee.normalizeDomains(input) - assertEquals("unnormalizable", output.first().domain) - } - - private fun creds( - domain: String? = null, - username: String? = null, - password: String? = null, - notes: String? = null, - domainTitle: String? = null, - ): LoginCredentials { - return LoginCredentials( - domainTitle = domainTitle, - domain = domain, - username = username, - password = password, - notes = notes, - ) + val output = testee.normalize("unnormalizable") + assertEquals("unnormalizable", output) } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt index 801ac953ae17..53ab876a2309 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt @@ -1,6 +1,5 @@ package com.duckduckgo.autofill.impl.importing -import com.duckduckgo.autofill.api.domain.app.LoginCredentials import org.junit.Assert.* import org.junit.Test @@ -31,13 +30,13 @@ class DefaultImportedCredentialValidatorTest { @Test fun whenDomainMissingThenIsValid() { - val missingDomain = fullyPopulatedCredentials().copy(domain = null) + val missingDomain = fullyPopulatedCredentials().copy(url = null) assertTrue(testee.isValid(missingDomain)) } @Test fun whenTitleIsMissingThenIsValid() { - val missingTitle = fullyPopulatedCredentials().copy(domainTitle = null) + val missingTitle = fullyPopulatedCredentials().copy(title = null) assertTrue(testee.isValid(missingTitle)) } @@ -58,12 +57,12 @@ class DefaultImportedCredentialValidatorTest { @Test fun whenDomainOnlyFieldPopulatedThenIsValid() { - assertTrue(testee.isValid(emptyCredentials().copy(domain = "example.com"))) + assertTrue(testee.isValid(emptyCredentials().copy(url = "example.com"))) } @Test fun whenTitleIsOnlyFieldPopulatedThenIsValid() { - assertTrue(testee.isValid(emptyCredentials().copy(domainTitle = "title"))) + assertTrue(testee.isValid(emptyCredentials().copy(title = "title"))) } @Test @@ -73,26 +72,26 @@ class DefaultImportedCredentialValidatorTest { @Test fun whenDomainIsAppPasswordThenIsNotValid() { - val appPassword = fullyPopulatedCredentials().copy(domain = "android://Jz-U_hg==@com.netflix.mediaclient/") + val appPassword = fullyPopulatedCredentials().copy(url = "android://Jz-U_hg==@com.netflix.mediaclient/") assertFalse(testee.isValid(appPassword)) } - private fun fullyPopulatedCredentials(): LoginCredentials { - return LoginCredentials( + private fun fullyPopulatedCredentials(): GoogleCsvLoginCredential { + return GoogleCsvLoginCredential( username = "username", password = "password", - domain = "example.com", - domainTitle = "example title", + url = "example.com", + title = "example title", notes = "notes", ) } - private fun emptyCredentials(): LoginCredentials { - return LoginCredentials( + private fun emptyCredentials(): GoogleCsvLoginCredential { + return GoogleCsvLoginCredential( username = null, password = null, - domain = null, - domainTitle = null, + url = null, + title = null, notes = null, ) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt index 9039c77c52d3..da3491f2b21b 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt @@ -21,11 +21,11 @@ class GooglePasswordManagerCsvCredentialConverterTest { private val parser: CsvCredentialParser = mock() private val fileReader: CsvFileReader = mock() private val passthroughValidator = object : ImportedCredentialValidator { - override fun isValid(loginCredentials: LoginCredentials): Boolean = true + override fun isValid(loginCredentials: GoogleCsvLoginCredential): Boolean = true } private val passthroughDomainNormalizer = object : DomainNameNormalizer { - override suspend fun normalizeDomains(unnormalized: List): List { - return unnormalized + override suspend fun normalize(unnormalizedUrl: String?): String? { + return unnormalizedUrl } } private val blobDecoder: GooglePasswordBlobDecoder = mock() @@ -65,21 +65,27 @@ class GooglePasswordManagerCsvCredentialConverterTest { assertEquals(1, result.loginCredentialsToImport.size) } - private suspend fun configureParseResult(passwords: List): CsvCredentialImportResult.Success { + @Test + fun whenFailureToParseThen() = runTest { + whenever(parser.parseCsv(any())).thenThrow(RuntimeException()) + testee.readCsv("") as CsvCredentialImportResult.Error + } + + private suspend fun configureParseResult(passwords: List): CsvCredentialImportResult.Success { whenever(parser.parseCsv(any())).thenReturn(ParseResult.Success(passwords)) return testee.readCsv("") as CsvCredentialImportResult.Success } private fun creds( - domain: String? = "example.com", + url: String? = "example.com", username: String? = "username", password: String? = "password", notes: String? = "notes", - domainTitle: String? = "example title", - ): LoginCredentials { - return LoginCredentials( - domainTitle = domainTitle, - domain = domain, + title: String? = "example title", + ): GoogleCsvLoginCredential { + return GoogleCsvLoginCredential( + title = title, + url = url, username = username, password = password, notes = notes, diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt index d48ccbe6e9cd..917135a2f9c0 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt @@ -1,6 +1,5 @@ package com.duckduckgo.autofill.impl.importing -import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Success import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities @@ -74,9 +73,9 @@ class GooglePasswordManagerCsvCredentialParserTest { val csv = "gpm_import_password_has_a_comma".readFile() with(testee.parseCsv(csv) as Success) { assertEquals(1, credentials.size) - val expected = LoginCredentials( - domain = "https://example.com", - domainTitle = "example.com", + val expected = GoogleCsvLoginCredential( + url = "https://example.com", + title = "example.com", username = "user", password = "password, a comma it has", notes = "notes", @@ -127,7 +126,7 @@ class GooglePasswordManagerCsvCredentialParserTest { val csv = "gpm_import_missing_title".readFile() with(testee.parseCsv(csv) as Success) { assertEquals(1, credentials.size) - credentials.first().verifyMatches(creds1.copy(domainTitle = null)) + credentials.first().verifyMatches(creds1.copy(title = null)) } } @@ -136,32 +135,32 @@ class GooglePasswordManagerCsvCredentialParserTest { val csv = "gpm_import_missing_domain".readFile() with(testee.parseCsv(csv) as Success) { assertEquals(1, credentials.size) - credentials.first().verifyMatches(creds1.copy(domain = null)) + credentials.first().verifyMatches(creds1.copy(url = null)) } } - private fun LoginCredentials.verifyMatchesCreds1() = verifyMatches(creds1) - private fun LoginCredentials.verifyMatchesCreds2() = verifyMatches(creds2) + private fun GoogleCsvLoginCredential.verifyMatchesCreds1() = verifyMatches(creds1) + private fun GoogleCsvLoginCredential.verifyMatchesCreds2() = verifyMatches(creds2) - private fun LoginCredentials.verifyMatches(expected: LoginCredentials) { - assertEquals(expected.domainTitle, domainTitle) - assertEquals(expected.domain, domain) + private fun GoogleCsvLoginCredential.verifyMatches(expected: GoogleCsvLoginCredential) { + assertEquals(expected.title, title) + assertEquals(expected.url, url) assertEquals(expected.username, username) assertEquals(expected.password, password) assertEquals(expected.notes, notes) } - private val creds1 = LoginCredentials( - domain = "https://example.com", - domainTitle = "example.com", + private val creds1 = GoogleCsvLoginCredential( + url = "https://example.com", + title = "example.com", username = "user", password = "password", notes = "note", ) - private val creds2 = LoginCredentials( - domain = "https://example.net", - domainTitle = "example.net", + private val creds2 = GoogleCsvLoginCredential( + url = "https://example.net", + title = "example.net", username = "user2", password = "password2", notes = "note2", diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt new file mode 100644 index 000000000000..cb9db23d1a7a --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt @@ -0,0 +1,152 @@ +package com.duckduckgo.autofill.impl.importing.gpm.webflow + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult.Error +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult.Success +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordSettings +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Companion.ENCRYPTED_PASSPHRASE_ERROR_URL +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.LoadStartPage +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.NavigatingBack +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedCannotImport +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedImportFlow +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class ImportGooglePasswordsWebFlowViewModelTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val credentialImporter: CredentialImporter = mock() + private val csvCredentialConverter: CsvCredentialConverter = mock() + private val autofillImportConfigStore: AutofillImportPasswordConfigStore = mock() + + private val testee = ImportGooglePasswordsWebFlowViewModel( + dispatchers = coroutineTestRule.testDispatcherProvider, + credentialImporter = credentialImporter, + csvCredentialConverter = csvCredentialConverter, + autofillImportConfigStore = autofillImportConfigStore, + ) + + @Test + fun whenOnViewCreatedThenLoadStartPageState() = runTest { + configureFeature(launchUrlGooglePasswords = "https://example.com") + testee.onViewCreated() + testee.viewState.test { + assertEquals(LoadStartPage("https://example.com"), awaitItem()) + } + } + + @Test + fun whenCsvParseErrorThenUserFinishedCannotImport() = runTest { + configureCsvParseError() + testee.viewState.test { + awaitItem() as UserFinishedCannotImport + } + } + + @Test + fun whenCsvParseSuccessNoCredentialsThenUserFinishedImportFlow() = runTest { + configureCsvSuccess(loginCredentialsToImport = emptyList()) + testee.viewState.test { + awaitItem() as UserFinishedImportFlow + } + } + + @Test + fun whenCsvParseSuccessWithCredentialsThenUserFinishedImportFlow() = runTest { + configureCsvSuccess(loginCredentialsToImport = listOf(creds())) + testee.viewState.test { + awaitItem() as UserFinishedImportFlow + } + } + + @Test + fun whenBackButtonPressedAndCannotGoBackThenUserCancelledImportFlowState() = runTest { + testee.onBackButtonPressed(url = "https://example.com", canGoBack = false) + testee.viewState.test { + awaitItem() as UserCancelledImportFlow + } + } + + @Test + fun whenBackButtonPressedAndCanGoBackThenNavigatingBackState() = runTest { + testee.onBackButtonPressed(url = "https://example.com", canGoBack = true) + testee.viewState.test { + awaitItem() as NavigatingBack + } + } + + @Test + fun whenCloseButtonPressedAndNotEncryptionErrorPageThenUserCancelledImportFlowState() = runTest { + testee.onCloseButtonPressed("https://example.com") + testee.viewState.test { + awaitItem() as UserCancelledImportFlow + } + } + + @Test + fun whenCloseButtonPressedOnEncryptionErrorPageThenUserCancelledImportFlowState() = runTest { + testee.onCloseButtonPressed(ENCRYPTED_PASSPHRASE_ERROR_URL) + testee.viewState.test { + awaitItem() as UserFinishedCannotImport + } + } + + private suspend fun configureFeature( + canImportFromGooglePasswords: Boolean = true, + launchUrlGooglePasswords: String = "https://example.com", + javascriptConfigGooglePasswords: String = "\"{}\"", + ) { + whenever(autofillImportConfigStore.getConfig()).thenReturn( + AutofillImportPasswordSettings( + canImportFromGooglePasswords = canImportFromGooglePasswords, + launchUrlGooglePasswords = launchUrlGooglePasswords, + javascriptConfigGooglePasswords = javascriptConfigGooglePasswords, + ), + ) + } + + private suspend fun configureCsvParseError() { + whenever(csvCredentialConverter.readCsv(any())).thenReturn(Error) + testee.onCsvAvailable("") + } + + private suspend fun configureCsvSuccess( + loginCredentialsToImport: List = emptyList(), + numberCredentialsInSource: Int = loginCredentialsToImport.size, + ) { + whenever(csvCredentialConverter.readCsv(any())).thenReturn(Success(numberCredentialsInSource, loginCredentialsToImport)) + testee.onCsvAvailable("") + } + + private fun creds( + domain: String? = "example.com", + username: String? = "username", + password: String? = "password", + notes: String? = "notes", + domainTitle: String? = "example title", + ): LoginCredentials { + return LoginCredentials( + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } +} 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 c83354c0ee20..77564c4fe5cb 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 @@ -23,6 +23,7 @@ import android.content.Intent import android.os.Bundle import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.IntentCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.lifecycleScope @@ -31,7 +32,6 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreen -import com.duckduckgo.autofill.api.AutofillScreens.ImportGooglePassword import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.InternalDevSettings import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager @@ -44,6 +44,12 @@ import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.In import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY_DETAILS +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Error +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Success +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.UserCancelled import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository @@ -162,10 +168,21 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } private val importGooglePasswordsFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - logcat { "cdr onActivityResult for Google Password Manager import flow. resultCode=${result.resultCode}" } + logcat { "onActivityResult for Google Password Manager import flow. resultCode=${result.resultCode}" } if (result.resultCode == Activity.RESULT_OK) { - observePasswordInputUpdates() + result.data?.let { + when (IntentCompat.getParcelableExtra(it, RESULT_KEY_DETAILS, ImportGooglePasswordResult::class.java)) { + is Success -> { + observePasswordInputUpdates() + } + Error -> { + "Failed to import passwords due to an error".showSnackbar() + } + is UserCancelled, null -> { + } + } + } } } @@ -276,7 +293,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } binding.importPasswordsLaunchGooglePasswordCustomFlow.setClickListener { - val intent = globalActivityStarter.startIntent(this, ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen) + val intent = globalActivityStarter.startIntent(this, AutofillImportViaGooglePasswordManagerScreen) importGooglePasswordsFlowLauncher.launch(intent) } diff --git a/autofill/autofill-internal/src/main/res/values/donottranslate.xml b/autofill/autofill-internal/src/main/res/values/donottranslate.xml index 4ec20b12f203..6d595e3710f0 100644 --- a/autofill/autofill-internal/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-internal/src/main/res/values/donottranslate.xml @@ -41,7 +41,7 @@ Launch Google Passwords (normal tab) Launch Google Passwords (import flow) Import CSV - %1$d passwords imported from Google + %1$d passwords imported from Google Maximum number of days since install OK