diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewSafeMessageListening.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewSafeMessageListening.kt
new file mode 100644
index 000000000000..d614b3167088
--- /dev/null
+++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewSafeMessageListening.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2024 DuckDuckGo
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.duckduckgo.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 b600554a3f76..ff119db2c586 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,8 +16,10 @@
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 {
@@ -48,6 +50,29 @@ 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/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" />
+
()
+
+ // Map>() = Map>()
+ private val fixedReplyProxyMap = mutableMapOf>()
+
+ @SuppressLint("RequiresFeature")
+ override suspend fun configureWebViewForBlobDownload(
+ webView: WebView,
+ callback: Callback,
+ ) {
+ withContext(dispatchers.main()) {
+ WebViewCompat.addDocumentStartJavaScript(webView, blobDownloadScript(), setOf("*"))
+ 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()
+ 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
+ }
+ }
+ }
+
+ @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
+ }
+}
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..a5ca8ee8a6cb
--- /dev/null
+++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.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.common.ui.DuckDuckGoActivity
+import com.duckduckgo.common.ui.viewbinding.viewBinding
+import com.duckduckgo.di.scopes.ActivityScope
+
+@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)
+ }
+}
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..e6714f1316be
--- /dev/null
+++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt
@@ -0,0 +1,317 @@
+/*
+ * 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.content.Intent
+import android.os.Bundle
+import android.view.View
+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.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+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.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.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.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
+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.launch
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+@InjectWith(FragmentScope::class)
+class ImportGooglePasswordsWebFlowFragment :
+ DuckDuckGoFragment(R.layout.fragment_import_google_passwords_webflow),
+ ProgressListener,
+ NewPageCallback,
+ ImportGooglePasswordAutofillEventListener,
+ ImportGooglePasswordAutofillCallback,
+ 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 csvPasswordImporter: CsvPasswordImporter
+
+ @Inject
+ lateinit var passwordImporter: PasswordImporter
+
+ val viewModel by lazy {
+ ViewModelProvider(requireActivity(), viewModelFactory)[ImportGooglePasswordsWebFlowViewModel::class.java]
+ }
+
+ private val autofillConfigurationJob = ConflatedJob()
+
+ private val binding: FragmentImportGooglePasswordsWebflowBinding by viewBinding()
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ initialiseToolbar()
+ configureWebView()
+ configureBackButtonHandler()
+ observeViewState()
+ loadFirstWebpage(activity?.intent)
+ }
+
+ private fun loadFirstWebpage(intent: Intent?) {
+ lifecycleScope.launch(dispatchers.main()) {
+ autofillConfigurationJob.join()
+
+ binding.webView.loadUrl(STARTING_URL)
+
+ viewModel.loadedStartingUrl()
+ }
+ }
+
+ private fun observeViewState() {
+ lifecycleScope.launch(dispatchers.main()) {
+ 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 UserCancelledImportFlow -> exitFlowAsCancellation(viewState.stage)
+ is NavigatingBack -> binding.webView.goBack()
+ }
+ }
+ }
+ }
+ }
+
+ private fun exitFlowAsCancellation(stage: String) {
+ (activity as ImportGooglePasswordsWebFlowActivity).exitUserCancelled(stage)
+ }
+
+ 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())
+ }
+ },
+ )
+ }
+ }
+
+ 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", "RequiresFeature")
+ private fun configureWebView() {
+ Timber.i("cdr Configuring WebView")
+ binding.webView.let { webView ->
+ webView.webChromeClient = ImportGooglePasswordsWebFlowWebChromeClient(this)
+ 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, 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, userAgent, contentDisposition, mimetype, contentLength ->
+ if (url.startsWith("blob:")) {
+ // if (isBlobDownloadWebViewFeatureEnabled) {
+ lifecycleScope.launch {
+ passwordBlobConsumer.postMessageToConvertBlobToDataUri(url)
+ }
+ }
+ }
+ }
+
+ 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?) {
+ viewModel.onPageStarted(url)
+ }
+
+ override fun onPageFinished(url: String?) {
+ viewModel.onPageFinished(url)
+ }
+
+ override suspend fun onCredentialsAvailableToInject(
+ autofillWebMessageRequest: AutofillWebMessageRequest,
+ 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) {
+ Timber.w("WebView url has changed since autofill request; bailing")
+ return@withContext
+ }
+
+ val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
+ autofillWebMessageRequest,
+ credentials,
+ triggerType,
+ CUSTOM_FLOW_TAB_ID,
+ )
+ dialog.show(childFragmentManager, SELECT_CREDENTIALS_FRAGMENT_TAG)
+ }
+ }
+
+ 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)
+ }
+
+ override suspend fun onCsvError() {
+ Timber.e("cdr Error decoding CSV")
+ val resultBundle = Bundle().also {
+ it.putParcelable(RESULT_KEY_DETAILS, Result.Error)
+ }
+ setFragmentResult(RESULT_KEY, resultBundle)
+ }
+
+ 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
new file mode 100644
index 000000000000..bd6adeb7b634
--- /dev/null
+++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt
@@ -0,0 +1,93 @@
+/*
+ * 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 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.common.utils.DispatcherProvider
+import com.duckduckgo.di.scopes.ActivityScope
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import timber.log.Timber
+
+@ContributesViewModel(ActivityScope::class)
+class ImportGooglePasswordsWebFlowViewModel @Inject constructor(
+ private val pixel: Pixel,
+) : ViewModel() {
+
+ @Inject
+ lateinit var emailManager: EmailManager
+
+ @Inject
+ lateinit var dispatchers: DispatcherProvider
+
+ private val _viewState = MutableStateFlow(ShowingWebContent)
+ val viewState: StateFlow = _viewState
+
+ fun onPageStarted(url: String?) {
+ Timber.i("onPageStarted: $url")
+ }
+
+ fun onPageFinished(url: String?) {
+ _viewState.value = ShowingWebContent
+ Timber.i("onPageFinished: $url")
+ }
+
+ 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")
+ return
+ }
+
+ _viewState.value = ViewState.NavigatingBack
+ }
+
+ private fun terminateFlowAsCancellation(stage: String) {
+ _viewState.value = ViewState.UserCancelledImportFlow(stage)
+ }
+
+ fun loadedStartingUrl() {
+ // pixel.fire(EMAIL_PROTECTION_IN_CONTEXT_MODAL_DISPLAYED)
+ }
+
+ fun onCloseButtonPressed(url: String?) {
+ terminateFlowAsCancellation(url ?: "unknown")
+ }
+
+ sealed interface ViewState {
+ data object ShowingWebContent : ViewState
+ data class UserCancelledImportFlow(val stage: String) : ViewState
+ data object NavigatingBack : ViewState
+ }
+
+ sealed interface BackButtonAction {
+ data object NavigateBack : BackButtonAction
+ }
+
+ companion object {
+ }
+}
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
new file mode 100644
index 000000000000..a39ee4242532
--- /dev/null
+++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowWebChromeClient.kt
@@ -0,0 +1,45 @@
+/*
+ * 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
new file mode 100644
index 000000000000..e183a3673b89
--- /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..7abf2df16f66
--- /dev/null
+++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/PasswordImporterCssScriptLoader.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.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,
+) : PasswordImporterScriptLoader {
+
+ private lateinit var contentScopeJS: String
+
+ override suspend fun getScript(): String {
+ return withContext(dispatchers.io()) {
+ getContentScopeJS()
+ .replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeJson())
+ .replace(USER_UNPROTECTED_DOMAINS_PLACEHOLDER, getUnprotectedDomainsJson())
+ .replace(USER_PREFERENCES_PLACEHOLDER, getUserPreferencesJson())
+ }
+ }
+
+ private fun getContentScopeJson(
+ showHintSignInButton: Boolean = true,
+ showHintSettingsButton: Boolean = true,
+ showHintExportButton: Boolean = true,
+ ): String = (
+ """{
+ "features":{
+ "passwordImport" : {
+ "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"
+ }
+ }
+ }
+ }
+ },
+ "unprotectedTemporary":[]
+ }
+
+ """.trimMargin()
+ )
+
+ 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("passwordImport.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..7339119699f2
--- /dev/null
+++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/autofill/AutofillNoOpCallbacks.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.AutofillWebMessageRequest
+import com.duckduckgo.autofill.api.Callback
+import com.duckduckgo.autofill.api.domain.app.LoginCredentials
+
+interface ImportGooglePasswordAutofillCallback : Callback {
+
+ override suspend fun onGeneratedPasswordAvailableToUse(
+ autofillWebMessageRequest: AutofillWebMessageRequest,
+ username: String?,
+ generatedPassword: String,
+ ) {}
+
+ override fun onCredentialsSaved(savedCredentials: LoginCredentials) {}
+
+ override fun showNativeChooseEmailAddressPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) {}
+ override suspend fun onCredentialsAvailableToSave(autofillWebMessageRequest: AutofillWebMessageRequest, credentials: LoginCredentials) {}
+ override fun showNativeInContextEmailProtectionSignupPrompt(autofillWebMessageRequest: AutofillWebMessageRequest) {}
+}
+
+interface ImportGooglePasswordAutofillEventListener : AutofillEventListener {
+ override fun onSelectedToSignUpForInContextEmailProtection(autofillWebMessageRequest: AutofillWebMessageRequest) {}
+ override fun onSavedCredentials(credentials: LoginCredentials) {}
+ override fun onUpdatedCredentials(credentials: LoginCredentials) {}
+ override fun onAutofillStateChange() {}
+}
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..eff5d24dd1e2 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-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..c83354c0ee20 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
@@ -31,6 +31,7 @@ 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
@@ -160,6 +161,14 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() {
}
}
+ private val importGooglePasswordsFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ logcat { "cdr onActivityResult for Google Password Manager import flow. resultCode=${result.resultCode}" }
+
+ if (result.resultCode == Activity.RESULT_OK) {
+ observePasswordInputUpdates()
+ }
+ }
+
private fun observePasswordInputUpdates() {
passwordImportWatcher += lifecycleScope.launch {
credentialImporter.getImportStatus().collect {
@@ -266,6 +275,10 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() {
startActivity(browserNav.openInNewTab(this@AutofillInternalSettingsActivity, googlePasswordsUrl))
}
}
+ binding.importPasswordsLaunchGooglePasswordCustomFlow.setClickListener {
+ val intent = globalActivityStarter.startIntent(this, ImportGooglePassword.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
diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/WebViewMessageListening.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/WebViewMessageListening.kt
new file mode 100644
index 000000000000..b612dea72384
--- /dev/null
+++ b/browser-api/src/main/java/com/duckduckgo/browser/api/WebViewMessageListening.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2024 DuckDuckGo
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.duckduckgo.browser.api
+
+interface WebViewMessageListening {
+
+ suspend fun isWebMessageListenerSupported(): Boolean
+}