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 +}