From 545343202f165419cab8b15931b00fa03e7e48d5 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Fri, 15 Nov 2024 12:16:41 +0000 Subject: [PATCH] Add custom webview for importing via GPM (#5097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/608920331025315/1207414915035673/f ### Description Adds the webflow which supports importing passwords directly from Google Password Manager (GPM). - This PR only adds the webflow and a way to invoke from autofill dev settings - The user-facing UI to start the flow and show the results of the import is in the next PR up in the stack The webflow can be augmented by visual nudges to help the user navigate the flow, which are governed by remote config. By default, there will be no nudges shown (you can test that flow too), so to show the nudges [apply the patch](https://app.asana.com/0/1208756808903423/1208756808903423/f) to use a fake remote config (needed until the remote config goes live). ### Steps to test this PR - [ ] [apply the remote config patch](https://app.asana.com/0/1208756808903423/1208756808903423/f) - [ ] Fresh install `internal` build, and go to autofill dev settings **Not signed into Google already / no password for Google saved in our passwords already** - [x] Tap on `Launch Google Passwords (import flow)` - [x] Verify the `Sign in` _pulses_ as way of a UI nudge on the first screen. Tap it. - [x] Sign into your Google account - [x] Verify the ⚙️ settings button _pulses_. Tap it. - [x] Verify the `Export passwords` button _pulses_. Tap it. Tap `Export` on the dialog. - [x] Re-enter your Google password when prompted - [x] Verify the number of passwords imported matches what you'd expect **Already signed into Google** Now that you're already signed into a Google account from previous test, let's use that for the next test. - [x] Tap on `Launch Google Passwords (import flow)` - [x] Verify you're already at the screen with the export button on it - [x] Verify the `Export passwords` button _pulses_. Tap it. Tap `Export` on the dialog. - [x] Re-enter your Google password when prompted, and verify the webflow ends as expected. **Signed out from Google / has Google password saved for autofilling** For this test, you need to add a login for google to our password manager and sign out from Google in the webview. - [x] Tap on `View saved logins`, and manually add your Google credentials (using `url = google.com`) - [x] Return to autofill dev settings - [x] Tap on `Launch Google Passwords (import flow)` - [x] Tap on profile icon up top-right and choose to `Sign out` - [x] Close the webflow You are now signed out, but have a credential ready to autofill. - [x] Tap on `Launch Google Passwords (import flow)` - [x] Tap `Sign in` button - [x] If it remembers your account, tap on your username. If not, verify we prompt to, and can, autofill your username - [x] Verify we prompt to, and can, autofill your password. _No more to do here._ --- .../browser/RealWebViewCapabilityChecker.kt | 4 +- .../src/main/AndroidManifest.xml | 4 + .../impl/importing/CsvCredentialConverter.kt | 33 +- .../impl/importing/CsvCredentialParser.kt | 21 +- .../impl/importing/DomainNameNormalizer.kt | 13 +- .../importing/GoogleCsvLoginCredential.kt | 28 ++ .../importing/ImportedCredentialValidator.kt | 15 +- .../blob/ImportGooglePasswordBlobConsumer.kt | 99 +++++ .../importing/blob/WebViewBlobDownloader.kt | 171 +++++++++ .../gpm/webflow/ImportGooglePasswordResult.kt | 37 ++ .../ImportGooglePasswordsWebFlowActivity.kt | 77 ++++ .../ImportGooglePasswordsWebFlowFragment.kt | 344 ++++++++++++++++++ .../ImportGooglePasswordsWebFlowViewModel.kt | 115 ++++++ ...portGooglePasswordsWebFlowWebViewClient.kt | 47 +++ .../PasswordImporterCssScriptLoader.kt | 103 ++++++ .../webflow/autofill/AutofillNoOpCallbacks.kt | 109 ++++++ ...tivity_import_google_passwords_webflow.xml | 40 ++ ...agment_import_google_passwords_webflow.xml | 28 ++ .../src/main/res/values/donottranslate.xml | 3 + .../DefaultDomainNameNormalizerTest.kt | 48 +-- .../DefaultImportedCredentialValidatorTest.kt | 27 +- ...sswordManagerCsvCredentialConverterTest.kt | 26 +- ...ePasswordManagerCsvCredentialParserTest.kt | 33 +- ...portGooglePasswordsWebFlowViewModelTest.kt | 152 ++++++++ .../AutofillInternalSettingsActivity.kt | 30 ++ .../activity_autofill_internal_settings.xml | 5 + .../src/main/res/values/donottranslate.xml | 2 + 27 files changed, 1500 insertions(+), 114 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/ImportGooglePasswordBlobConsumer.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/WebViewBlobDownloader.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt create mode 100644 autofill/autofill-impl/src/main/res/layout/activity_import_google_passwords_webflow.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/fragment_import_google_passwords_webflow.xml create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt 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/autofill/autofill-impl/src/main/AndroidManifest.xml b/autofill/autofill-impl/src/main/AndroidManifest.xml index 6f29e9796fbc..01a906eb9004 100644 --- a/autofill/autofill-impl/src/main/AndroidManifest.xml +++ b/autofill/autofill-impl/src/main/AndroidManifest.xml @@ -15,6 +15,10 @@ android:name=".email.incontext.EmailProtectionInContextSignupActivity" android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|navigation|keyboard" android:exported="false" /> + ) : 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/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt new file mode 100644 index 000000000000..e7386b06504d --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GoogleCsvLoginCredential.kt @@ -0,0 +1,28 @@ +/* + * 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 + +/** + * 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/ImportGooglePasswordBlobConsumer.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/ImportGooglePasswordBlobConsumer.kt new file mode 100644 index 000000000000..6efade960643 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/ImportGooglePasswordBlobConsumer.kt @@ -0,0 +1,99 @@ +/* + * 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.WebMessageCompat +import androidx.webkit.WebViewCompat +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.impl.importing.blob.GooglePasswordBlobConsumer.Callback +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.ByteString.Companion.encode + +interface GooglePasswordBlobConsumer { + suspend fun configureWebViewForBlobDownload( + webView: WebView, + callback: Callback, + ) + + suspend fun postMessageToConvertBlobToDataUri(url: String) + + interface Callback { + suspend fun onCsvAvailable(csv: String) + suspend fun onCsvError() + } +} + +@ContributesBinding(FragmentScope::class) +class ImportGooglePasswordBlobConsumer @Inject constructor( + private val webViewBlobDownloader: WebViewBlobDownloader, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : GooglePasswordBlobConsumer { + + // 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()) { + webViewBlobDownloader.addBlobDownloadSupport(webView) + + WebViewCompat.addWebMessageListener( + webView, + "ddgBlobDownloadObj", + setOf("*"), + ) { _, message, sourceOrigin, _, replyProxy -> + val data = message.data ?: return@addWebMessageListener + appCoroutineScope.launch(dispatchers.io()) { + processReceivedWebMessage(data, message, sourceOrigin, replyProxy, callback) + } + } + } + } + + private suspend fun processReceivedWebMessage( + data: String, + message: WebMessageCompat, + sourceOrigin: Uri, + replyProxy: JavaScriptReplyProxy, + callback: Callback, + ) { + if (data.startsWith("data:")) { + kotlin.runCatching { + callback.onCsvAvailable(data) + }.onFailure { callback.onCsvError() } + } else if (message.data?.startsWith("Ping:") == true) { + val locationRef = message.data.toString().encode().md5().toString() + webViewBlobDownloader.storeReplyProxy(sourceOrigin.toString(), replyProxy, locationRef) + } + } + + override suspend fun postMessageToConvertBlobToDataUri(url: String) { + webViewBlobDownloader.convertBlobToDataUri(url) + } +} 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..df019cc808d1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/blob/WebViewBlobDownloader.kt @@ -0,0 +1,171 @@ +/* + * 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.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext + +/** + * 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, + private val dispatchers: DispatcherProvider, +) : WebViewBlobDownloader { + + private val fixedReplyProxyMap = mutableMapOf>() + + @SuppressLint("RequiresFeature") + override suspend fun addBlobDownloadSupport(webView: WebView) { + withContext(dispatchers.main()) { + if (isBlobDownloadWebViewFeatureEnabled()) { + WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) + } + } + } + + @SuppressLint("RequiresFeature") + override suspend fun convertBlobToDataUri(blobUrl: String) { + withContext(dispatchers.io()) { + for ((key, proxies) in fixedReplyProxyMap) { + if (sameOrigin(blobUrl.removePrefix("blob:"), key)) { + withContext(dispatchers.main()) { + for (replyProxy in proxies.values) { + replyProxy.postMessage(blobUrl) + } + } + return@withContext + } + } + } + } + + 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/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 new file mode 100644 index 000000000000..97838bcddec1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt @@ -0,0 +1,77 @@ +/* + * 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.content.Intent +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.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) +class ImportGooglePasswordsWebFlowActivity : DuckDuckGoActivity() { + + val binding: ActivityImportGooglePasswordsWebflowBinding by viewBinding() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + configureResultListeners() + launchImportFragment() + } + + private fun launchImportFragment() { + supportFragmentManager.commit { + replace(R.id.fragment_container, ImportGooglePasswordsWebFlowFragment()) + } + } + + private fun configureResultListeners() { + supportFragmentManager.setFragmentResultListener(RESULT_KEY, this) { _, result -> + exitWithResult(result) + } + } + + private fun exitWithResult(resultBundle: Bundle) { + setResult(RESULT_OK, Intent().putExtras(resultBundle)) + finish() + } + + fun exitUserCancelled(stage: String) { + val result = Bundle().apply { + putParcelable(RESULT_KEY_DETAILS, UserCancelled(stage)) + } + 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 new file mode 100644 index 000000000000..24a1842a7051 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt @@ -0,0 +1,344 @@ +/* + * 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.annotation.SuppressLint +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 +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.webkit.WebViewCompat +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.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.blob.GooglePasswordBlobConsumer +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.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.utils.DispatcherProvider +import com.duckduckgo.common.utils.FragmentViewModelFactory +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.user.agent.api.UserAgentProvider +import javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +@InjectWith(FragmentScope::class) +class ImportGooglePasswordsWebFlowFragment : + DuckDuckGoFragment(R.layout.fragment_import_google_passwords_webflow), + NewPageCallback, + NoOpAutofillCallback, + NoOpEmailProtectionInContextSignupFlowListener, + NoOpEmailProtectionUserPromptListener, + NoOpAutofillEventListener, + GooglePasswordBlobConsumer.Callback { + + @Inject + lateinit var userAgentProvider: UserAgentProvider + + @Inject + lateinit var dispatchers: DispatcherProvider + + @Inject + lateinit var pixel: Pixel + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + @Inject + lateinit var autofillCapabilityChecker: AutofillCapabilityChecker + + @Inject + lateinit var credentialAutofillDialogFactory: CredentialAutofillDialogFactory + + @Inject + lateinit var browserAutofill: BrowserAutofill + + @Inject + lateinit var autofillFragmentResultListeners: PluginPoint + + @Inject + lateinit var passwordBlobConsumer: GooglePasswordBlobConsumer + + @Inject + lateinit var passwordImporterScriptLoader: PasswordImporterScriptLoader + + @Inject + lateinit var browserAutofillConfigurator: BrowserAutofill.Configurator + + private var binding: FragmentImportGooglePasswordsWebflowBinding? = null + + private val viewModel by lazy { + ViewModelProvider(requireActivity(), viewModelFactory)[ImportGooglePasswordsWebFlowViewModel::class.java] + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + binding = FragmentImportGooglePasswordsWebflowBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + initialiseToolbar() + configureWebView() + configureBackButtonHandler() + observeViewState() + viewModel.onViewCreated() + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun loadFirstWebpage(url: String) { + lifecycleScope.launch(dispatchers.main()) { + binding?.webView?.let { + it.loadUrl(url) + viewModel.firstPageLoading() + } + } + } + + private fun observeViewState() { + viewModel.viewState + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { viewState -> + when (viewState) { + is UserFinishedImportFlow -> exitFlowAsSuccess() + is UserCancelledImportFlow -> exitFlowAsCancellation(viewState.stage) + is UserFinishedCannotImport -> exitFlowAsImpossibleToImport() + is NavigatingBack -> binding?.webView?.goBack() + is LoadStartPage -> loadFirstWebpage(viewState.initialLaunchUrl) + is WebContentShowing, Initializing -> { + // no-op + } + } + } + .launchIn(lifecycleScope) + } + + private fun exitFlowAsCancellation(stage: String) { + (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() ?: false) + } + }, + ) + } + } + + private fun initialiseToolbar() { + with(getToolbar()) { + title = getString(R.string.autofillImportGooglePasswordsWebFlowTitle) + setNavigationIconAsCross() + setNavigationOnClickListener { viewModel.onCloseButtonPressed(binding?.webView?.url) } + } + } + + private fun Toolbar.setNavigationIconAsCross() { + setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_close_24) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun configureWebView() { + binding?.webView?.let { webView -> + webView.webViewClient = ImportGooglePasswordsWebFlowWebViewClient(this) + + webView.settings.apply { + userAgentString = userAgentProvider.userAgent() + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + setSupportMultipleWindows(true) + databaseEnabled = false + setSupportZoom(true) + } + + configureDownloadInterceptor(webView) + configureAutofill(webView) + + lifecycleScope.launch { + passwordBlobConsumer.configureWebViewForBlobDownload(webView, this@ImportGooglePasswordsWebFlowFragment) + configurePasswordImportJavascript(webView) + } + } + } + + private fun configureAutofill(it: WebView) { + lifecycleScope.launch { + browserAutofill.addJsInterface( + it, + this@ImportGooglePasswordsWebFlowFragment, + this@ImportGooglePasswordsWebFlowFragment, + this@ImportGooglePasswordsWebFlowFragment, + CUSTOM_FLOW_TAB_ID, + ) + } + + autofillFragmentResultListeners.getPlugins().forEach { plugin -> + setFragmentResultListener(plugin.resultKey(CUSTOM_FLOW_TAB_ID)) { _, result -> + context?.let { ctx -> + plugin.processResult( + result = result, + context = ctx, + tabId = CUSTOM_FLOW_TAB_ID, + fragment = this@ImportGooglePasswordsWebFlowFragment, + autofillCallback = this@ImportGooglePasswordsWebFlowFragment, + ) + } + } + } + } + + private fun configureDownloadInterceptor(it: WebView) { + it.setDownloadListener { url, _, _, _, _ -> + if (url.startsWith("blob:")) { + lifecycleScope.launch { + passwordBlobConsumer.postMessageToConvertBlobToDataUri(url) + } + } + } + } + + @SuppressLint("RequiresFeature") + private suspend fun configurePasswordImportJavascript(webView: WebView) { + val script = passwordImporterScriptLoader.getScript() + WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) + } + + private fun getToolbar() = (activity as ImportGooglePasswordsWebFlowActivity).binding.includeToolbar.toolbar + + override fun onPageStarted(url: String?) { + binding?.let { + browserAutofillConfigurator.configureAutofillForCurrentPage(it.webView, url) + } + } + + override suspend fun onCredentialsAvailableToInject( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + ) { + withContext(dispatchers.main()) { + 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( + url, + credentials, + triggerType, + CUSTOM_FLOW_TAB_ID, + ) + dialog.show(childFragmentManager, SELECT_CREDENTIALS_FRAGMENT_TAG) + } + } + + override suspend fun onCsvAvailable(csv: String) { + viewModel.onCsvAvailable(csv) + } + + override suspend fun onCsvError() { + 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 + } + 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 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 new file mode 100644 index 000000000000..125d39083c16 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt @@ -0,0 +1,115 @@ +/* + * 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 androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +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 dispatchers: DispatcherProvider, + private val credentialImporter: CredentialImporter, + private val csvCredentialConverter: CsvCredentialConverter, + private val autofillImportConfigStore: AutofillImportPasswordConfigStore, +) : ViewModel() { + + private val _viewState = MutableStateFlow(Initializing) + val viewState: StateFlow = _viewState + + fun onViewCreated() { + viewModelScope.launch(dispatchers.io()) { + _viewState.value = ViewState.LoadStartPage(autofillImportConfigStore.getConfig().launchUrlGooglePasswords) + } + } + + suspend fun onCsvAvailable(csv: String) { + when (val parseResult = csvCredentialConverter.readCsv(csv)) { + is CsvCredentialImportResult.Success -> onCsvParsed(parseResult) + is CsvCredentialImportResult.Error -> onCsvError() + } + } + + private suspend fun onCsvParsed(parseResult: CsvCredentialImportResult.Success) { + credentialImporter.import(parseResult.loginCredentialsToImport, parseResult.numberCredentialsInSource) + _viewState.value = ViewState.UserFinishedImportFlow + } + + 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, + ) { + // 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") + return + } + + _viewState.value = ViewState.NavigatingBack + } + + private fun terminateFlowAsCancellation(stage: String) { + _viewState.value = ViewState.UserCancelledImportFlow(stage) + } + + fun firstPageLoading() { + _viewState.value = ViewState.WebContentShowing + } + + sealed interface 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 + } + + sealed interface BackButtonAction { + data object NavigateBack : BackButtonAction + } + + 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/ImportGooglePasswordsWebFlowWebViewClient.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt new file mode 100644 index 000000000000..98d558a4d3a1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebViewClient.kt @@ -0,0 +1,47 @@ +/* + * 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.graphics.Bitmap +import android.webkit.WebView +import android.webkit.WebViewClient +import javax.inject.Inject + +class ImportGooglePasswordsWebFlowWebViewClient @Inject constructor( + private val callback: NewPageCallback, +) : WebViewClient() { + + interface NewPageCallback { + fun onPageStarted(url: String?) {} + fun onPageFinished(url: String?) {} + } + + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: Bitmap?, + ) { + callback.onPageStarted(url) + } + + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + callback.onPageFinished(url) + } +} 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 new file mode 100644 index 000000000000..be04d3ffa89e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt @@ -0,0 +1,103 @@ +/* + * 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 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 +import java.io.BufferedReader +import javax.inject.Inject +import kotlinx.coroutines.withContext + +interface PasswordImporterScriptLoader { + suspend fun getScript(): String +} + +@ContributesBinding(FragmentScope::class) +class PasswordImporterCssScriptLoader @Inject constructor( + private val dispatchers: DispatcherProvider, + private val configStore: AutofillImportPasswordConfigStore, +) : PasswordImporterScriptLoader { + + private lateinit var contentScopeJS: String + + override suspend fun getScript(): String { + return withContext(dispatchers.io()) { + getContentScopeJS() + .replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeJson(loadSettingsJson())) + .replace(USER_UNPROTECTED_DOMAINS_PLACEHOLDER, getUnprotectedDomainsJson()) + .replace(USER_PREFERENCES_PLACEHOLDER, getUserPreferencesJson()) + } + } + + /** + * 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":{ + "autofillPasswordImport" : { + "state": "enabled", + "exceptions": [], + "settings": $settingsJson + } + }, + "unprotectedTemporary":[] + } + + """.trimMargin() + } + + private suspend fun loadSettingsJson(): String { + return configStore.getConfig().javascriptConfigGooglePasswords + } + + private fun getUserPreferencesJson(): String { + return """ + { + "platform":{ + "name":"android" + }, + "messageCallback": '', + "javascriptInterface": '' + } + """.trimMargin() + } + + private fun getUnprotectedDomainsJson(): String = "[]" + + private fun getContentScopeJS(): String { + if (!this::contentScopeJS.isInitialized) { + contentScopeJS = loadJs("autofillPasswordImport.js") + } + return contentScopeJS + } + + companion object { + private const val CONTENT_SCOPE_PLACEHOLDER = "\$CONTENT_SCOPE$" + private const val USER_UNPROTECTED_DOMAINS_PLACEHOLDER = "\$USER_UNPROTECTED_DOMAINS$" + private const val USER_PREFERENCES_PLACEHOLDER = "\$USER_PREFERENCES$" + } + + private fun loadJs(resourceName: String): String = readResource(resourceName).use { it?.readText() }.orEmpty() + + private fun readResource(resourceName: String): BufferedReader? { + return javaClass.classLoader?.getResource(resourceName)?.openStream()?.bufferedReader() + } +} 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 new file mode 100644 index 000000000000..8c9a35f141f7 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt @@ -0,0 +1,109 @@ +/* + * 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.autofill + +import com.duckduckgo.autofill.api.AutofillEventListener +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 NoOpAutofillCallback : Callback { + override suspend fun onCredentialsAvailableToInject( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + ) { + } + + override suspend fun onCredentialsAvailableToSave( + currentUrl: String, + credentials: LoginCredentials, + ) { + } + + override suspend fun onGeneratedPasswordAvailableToUse( + 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 onSelectedToSignUpForInContextEmailProtection() { + } + + 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 NoOpEmailProtectionUserPromptListener : EmailProtectionUserPromptListener { + override fun showNativeInContextEmailProtectionSignupPrompt() { + } + + override fun showNativeChooseEmailAddressPrompt() { + } +} diff --git a/autofill/autofill-impl/src/main/res/layout/activity_import_google_passwords_webflow.xml b/autofill/autofill-impl/src/main/res/layout/activity_import_google_passwords_webflow.xml new file mode 100644 index 000000000000..ee8f7196ef37 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/activity_import_google_passwords_webflow.xml @@ -0,0 +1,40 @@ + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/fragment_import_google_passwords_webflow.xml b/autofill/autofill-impl/src/main/res/layout/fragment_import_google_passwords_webflow.xml new file mode 100644 index 000000000000..fd1bb915bce6 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/fragment_import_google_passwords_webflow.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/values/donottranslate.xml b/autofill/autofill-impl/src/main/res/values/donottranslate.xml index 633f147275b8..fa2047cdba76 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -17,4 +17,7 @@ Passwords + + Import Google Passwords + %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 d3914bfd444d..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 @@ -43,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 @@ -160,6 +167,25 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + private val importGooglePasswordsFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + logcat { "onActivityResult for Google Password Manager import flow. resultCode=${result.resultCode}" } + + if (result.resultCode == Activity.RESULT_OK) { + 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 -> { + } + } + } + } + } + private fun observePasswordInputUpdates() { passwordImportWatcher += lifecycleScope.launch { credentialImporter.getImportStatus().collect { @@ -266,6 +292,10 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { startActivity(browserNav.openInNewTab(this@AutofillInternalSettingsActivity, googlePasswordsUrl)) } } + binding.importPasswordsLaunchGooglePasswordCustomFlow.setClickListener { + val intent = globalActivityStarter.startIntent(this, AutofillImportViaGooglePasswordManagerScreen) + importGooglePasswordsFlowLauncher.launch(intent) + } binding.importPasswordsImportCsv.setClickListener { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { diff --git a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml index 1646042b7d70..e6dd61c948f7 100644 --- a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml +++ b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml @@ -92,6 +92,11 @@ android:layout_height="wrap_content" app:primaryText="@string/autofillDevSettingsImportPasswordsTitle" /> + Import Passwords Launch Google Passwords (normal tab) + Launch Google Passwords (import flow) Import CSV + %1$d passwords imported from Google Maximum number of days since install OK