diff --git a/autofill/autofill-impl/build.gradle b/autofill/autofill-impl/build.gradle index 2815b368f35d..7b54e896b99b 100644 --- a/autofill/autofill-impl/build.gradle +++ b/autofill/autofill-impl/build.gradle @@ -48,6 +48,7 @@ dependencies { implementation project(path: ':settings-api') // temporary until we release new settings implementation project(':library-loader-api') implementation project(':saved-sites-api') + implementation project(':cookies-api') anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') diff --git a/autofill/autofill-impl/src/main/AndroidManifest.xml b/autofill/autofill-impl/src/main/AndroidManifest.xml index 92065bd64b75..ae63c6af166f 100644 --- a/autofill/autofill-impl/src/main/AndroidManifest.xml +++ b/autofill/autofill-impl/src/main/AndroidManifest.xml @@ -21,6 +21,10 @@ android:name=".importing.gpm.webflow.ImportGooglePasswordsWebFlowActivity" android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|navigation|keyboard" android:exported="false" /> + + logcat(WARN) { "Bookmark zip download failed: ${e.asLog()}" } + Error.DownloadError + } + } + + private suspend fun processBookmarkZip( + zipUri: Uri, + folderName: String, + ): ImportResult = + runCatching { + val extractionResult = bookmarkExtractor.extractBookmarksFromFile(zipUri) + handleExtractionResult(extractionResult, folderName) + }.getOrElse { e -> + logcat(WARN) { "Error processing bookmark zip: ${e.asLog()}" } + ParseError + }.also { + cleanupZipFile(zipUri) + } + + private suspend fun handleExtractionResult( + extractionResult: ExtractionResult, + folderName: String, + ): ImportResult = + when (extractionResult) { + is ExtractionResult.Success -> { + val importResult = + takeoutBookmarkImporter.importBookmarks( + extractionResult.tempFileUri, + ImportFolder.Folder(folderName), + ) + handleImportResult(importResult) + } + + is ExtractionResult.Error -> { + logcat(WARN) { "Error extracting bookmarks from zip" } + ParseError + } + } + + private fun handleImportResult(importResult: ImportSavedSitesResult): ImportResult = + when (importResult) { + is ImportSavedSitesResult.Success -> { + val importedCount = importResult.savedSites.size + logcat { "Successfully imported $importedCount bookmarks" } + ImportResult.Success(importedCount) + } + + is ImportSavedSitesResult.Error -> { + logcat(WARN) { "Error importing bookmarks: ${importResult.exception.message}" } + Error.ImportError + } + } + + private fun cleanupZipFile(zipUri: Uri) { + runCatching { + val zipFile = File(zipUri.path ?: return) + if (zipFile.exists() && zipFile.delete()) { + logcat { "Cleaned up downloaded zip file: ${zipFile.absolutePath}" } + } + }.onFailure { logcat(WARN) { "Failed to cleanup downloaded zip file: ${it.asLog()}" } } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporter.kt index 83f553d22264..b3ad879340ad 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporter.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporter.kt @@ -23,23 +23,25 @@ import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult import com.duckduckgo.savedsites.api.service.SavedSitesImporter import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.withContext import java.io.File import javax.inject.Inject -import kotlinx.coroutines.withContext /** * Interface for importing bookmarks with flexible destination handling. * Supports both root-level imports and folder-based imports while preserving structure. */ interface TakeoutBookmarkImporter { - /** - * Imports bookmarks from HTML content to the specified destination. - * @param htmlContent The HTML bookmark content to import (in Netscape format) + * Imports bookmarks from a temporary HTML file to the specified destination. The file will be deleted after import. + * @param tempFileUri URI of the temporary HTML file containing bookmark content (in Netscape format) * @param destination Where to import the bookmarks (Root or named Folder within bookmarks root) * @return ImportSavedSitesResult indicating success with imported items or error */ - suspend fun importBookmarks(htmlContent: String, destination: ImportFolder): ImportSavedSitesResult + suspend fun importBookmarks( + tempFileUri: Uri, + destination: ImportFolder, + ): ImportSavedSitesResult } @ContributesBinding(AppScope::class) @@ -47,26 +49,24 @@ class RealTakeoutBookmarkImporter @Inject constructor( private val savedSitesImporter: SavedSitesImporter, private val dispatchers: DispatcherProvider, ) : TakeoutBookmarkImporter { - - override suspend fun importBookmarks(htmlContent: String, destination: ImportFolder): ImportSavedSitesResult { - return withContext(dispatchers.io()) { - import(htmlContent = htmlContent, destination = destination) - } - } - - private suspend fun import(htmlContent: String, destination: ImportFolder = ImportFolder.Root): ImportSavedSitesResult { - return try { - // saved sites importer needs a file uri, so we create a temp file here - val tempFile = File.createTempFile("bookmark_import_", ".html") + override suspend fun importBookmarks( + tempFileUri: Uri, + destination: ImportFolder, + ): ImportSavedSitesResult = + withContext(dispatchers.io()) { try { - tempFile.writeText(htmlContent) - return savedSitesImporter.import(Uri.fromFile(tempFile), destination) + savedSitesImporter.import(tempFileUri, destination) + } catch (exception: Exception) { + ImportSavedSitesResult.Error(exception) } finally { - // delete the temp file after import - tempFile.takeIf { it.exists() }?.delete() + cleanupTempFile(tempFileUri) } - } catch (exception: Exception) { - ImportSavedSitesResult.Error(exception) + } + + private fun cleanupTempFile(tempFileUri: Uri) { + runCatching { + val filePath = tempFileUri.path ?: return + File(filePath).delete() } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/store/BookmarkImportConfigStore.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/store/BookmarkImportConfigStore.kt new file mode 100644 index 000000000000..73dfead4a60b --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/store/BookmarkImportConfigStore.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 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.takeout.store + +import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import kotlinx.coroutines.withContext +import org.json.JSONObject +import javax.inject.Inject + +interface BookmarkImportConfigStore { + suspend fun getConfig(): BookmarkImportSettings +} + +data class BookmarkImportSettings( + val canImportFromGoogleTakeout: Boolean, + val launchUrlGoogleTakeout: String, + val canInjectJavascript: Boolean, + val javascriptConfigGoogleTakeout: String, +) + +@ContributesBinding(AppScope::class) +class BookmarkImportConfigStoreImpl @Inject constructor( + private val autofillFeature: AutofillFeature, + private val dispatchers: DispatcherProvider, + private val moshi: Moshi, +) : BookmarkImportConfigStore { + private val jsonAdapter: JsonAdapter by lazy { + moshi.adapter(ImportConfigJson::class.java) + } + + override suspend fun getConfig(): BookmarkImportSettings = + withContext(dispatchers.io()) { + val config = + autofillFeature.canImportBookmarksFromGoogleTakeout().getSettings()?.let { + runCatching { + jsonAdapter.fromJson(it) + }.getOrNull() + } + + BookmarkImportSettings( + canImportFromGoogleTakeout = autofillFeature.canImportBookmarksFromGoogleTakeout().isEnabled(), + launchUrlGoogleTakeout = config?.launchUrl ?: LAUNCH_URL_DEFAULT, + canInjectJavascript = config?.canInjectJavascript ?: CAN_INJECT_JAVASCRIPT_DEFAULT, + javascriptConfigGoogleTakeout = config?.javascriptConfig?.toString() ?: JAVASCRIPT_CONFIG_DEFAULT, + ) + } + + companion object { + internal const val JAVASCRIPT_CONFIG_DEFAULT = "\"{}\"" + internal const val CAN_INJECT_JAVASCRIPT_DEFAULT = true + + internal const val LAUNCH_URL_DEFAULT = "https://takeout.google.com/settings/takeout" + } + + private data class ImportConfigJson( + val launchUrl: String? = null, + val canInjectJavascript: Boolean = CAN_INJECT_JAVASCRIPT_DEFAULT, + val javascriptConfig: JSONObject? = null, + ) +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarkResult.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarkResult.kt new file mode 100644 index 000000000000..99f6e01b1241 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarkResult.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 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.takeout.webflow + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed interface ImportGoogleBookmarkResult : Parcelable { + @Parcelize + data class Success( + val importedCount: Int, + ) : ImportGoogleBookmarkResult + + @Parcelize + data class UserCancelled( + val stage: String, + ) : ImportGoogleBookmarkResult + + @Parcelize + data class Error( + val reason: UserCannotImportReason, + ) : ImportGoogleBookmarkResult + + companion object { + const val RESULT_KEY = "importBookmarkResult" + const val RESULT_KEY_DETAILS = "importBookmarkResultDetails" + } +} + +sealed interface UserCannotImportReason : Parcelable { + @Parcelize + data object ErrorParsingBookmarks : UserCannotImportReason + + @Parcelize + data object DownloadError : UserCannotImportReason +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowActivity.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowActivity.kt new file mode 100644 index 000000000000..f0e90768ded7 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowActivity.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 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.takeout.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.ActivityImportGoogleBookmarksWebflowBinding +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmark.AutofillImportViaGoogleTakeoutScreen +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(AutofillImportViaGoogleTakeoutScreen::class) +class ImportGoogleBookmarksWebFlowActivity : DuckDuckGoActivity() { + val binding: ActivityImportGoogleBookmarksWebflowBinding 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, ImportGoogleBookmarksWebFlowFragment()) + } + } + + private fun configureResultListeners() { + supportFragmentManager.setFragmentResultListener(ImportGoogleBookmarkResult.Companion.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( + ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, + ImportGoogleBookmarkResult.UserCancelled(stage), + ) + } + exitWithResult(result) + } +} + +object ImportGoogleBookmark { + data object AutofillImportViaGoogleTakeoutScreen : ActivityParams { + private fun readResolve(): Any = AutofillImportViaGoogleTakeoutScreen + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowFragment.kt new file mode 100644 index 000000000000..f0b5f001c7dc --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowFragment.kt @@ -0,0 +1,462 @@ +/* + * Copyright (c) 2025 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.takeout.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.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.api.domain.app.LoginTriggerType.AUTOPROMPT +import com.duckduckgo.autofill.impl.InternalCallback +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.configuration.InternalBrowserAutofillConfigurator +import com.duckduckgo.autofill.impl.databinding.FragmentImportGoogleBookmarksWebflowBinding +import com.duckduckgo.autofill.impl.importing.gpm.webflow.GoogleImporterScriptLoader +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.autofill.impl.importing.takeout.store.BookmarkImportConfigStore +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowAsFailure +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowWithSuccess +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.InjectCredentialsFromReauth +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.NoCredentialsAvailable +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.PromptUserToSelectFromStoredCredentials +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.Initializing +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.LoadingWebPage +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.NavigatingBack +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.ShowError +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.ShowWebPage +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.UserCancelledImportFlow +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.UserFinishedCannotImport +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.PASSWORD +import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails +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 kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.WARN +import logcat.logcat +import javax.inject.Inject + +@InjectWith(FragmentScope::class) +class ImportGoogleBookmarksWebFlowFragment : + DuckDuckGoFragment(R.layout.fragment_import_google_bookmarks_webflow), + InternalCallback, + NoOpEmailProtectionInContextSignupFlowListener, + NoOpEmailProtectionUserPromptListener, + NoOpAutofillEventListener, + ImportGoogleBookmarksWebFlowWebViewClient.NewPageCallback { + @Inject + lateinit var userAgentProvider: UserAgentProvider + + @Inject + lateinit var dispatchers: DispatcherProvider + + @Inject + lateinit var credentialAutofillDialogFactory: CredentialAutofillDialogFactory + + @Inject + lateinit var googleImporterScriptLoader: GoogleImporterScriptLoader + + @Inject + lateinit var importBookmarkConfig: BookmarkImportConfigStore + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + @Inject + lateinit var browserAutofill: BrowserAutofill + + @Inject + lateinit var autofillFragmentResultListeners: PluginPoint + + @Inject + lateinit var browserAutofillConfigurator: InternalBrowserAutofillConfigurator + + private var binding: FragmentImportGoogleBookmarksWebflowBinding? = null + + private val viewModel by lazy { + ViewModelProvider(requireActivity(), viewModelFactory)[ImportGoogleBookmarksWebFlowViewModel::class.java] + } + + companion object { + private const val CUSTOM_FLOW_TAB_ID = "bookmark-import-webflow" + + private const val SELECT_CREDENTIALS_FRAGMENT_TAG = "autofillSelectCredentialsDialog" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + binding = FragmentImportGoogleBookmarksWebflowBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + initialiseToolbar() + configureWebView() + configureBackButtonHandler() + observeViewState() + observeCommands() + lifecycleScope.launch { + viewModel.loadInitialWebpage() + } + } + + 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 UserCancelledImportFlow -> exitFlowAsCancellation(viewState.stage) + is UserFinishedCannotImport -> exitFlowAsError(viewState.reason) + is ShowError -> exitFlowAsError(viewState.reason) + is LoadingWebPage -> loadFirstWebpage(viewState.url) + is NavigatingBack -> binding?.webView?.goBack() + is Initializing -> {} + is ShowWebPage -> {} + } + }.launchIn(lifecycleScope) + } + + private fun observeCommands() { + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { command -> + logcat { "Received command: ${command::class.simpleName}" } + when (command) { + is InjectCredentialsFromReauth -> { + injectReauthenticationCredentials(url = command.url, username = command.username, password = command.password) + } + is NoCredentialsAvailable -> { + // Inject null to indicate no credentials available + browserAutofill.injectCredentials(null) + } + is PromptUserToSelectFromStoredCredentials -> + showCredentialChooserDialog( + command.originalUrl, + command.credentials, + command.triggerType, + ) + is ExitFlowWithSuccess -> exitFlowAsSuccess(command.importedCount) + is ExitFlowAsFailure -> exitFlowAsError(command.reason) + } + }.launchIn(lifecycleScope) + } + + private suspend fun injectReauthenticationCredentials( + url: String?, + username: String?, + password: String?, + ) { + withContext(dispatchers.main()) { + binding?.webView?.let { + if (it.url != url) { + logcat(WARN) { "WebView url has changed since autofill request; bailing" } + return@withContext + } + + val credentials = + LoginCredentials( + domain = url, + username = username, + password = password, + ) + + logcat { "Injecting re-authentication credentials" } + browserAutofill.injectCredentials(credentials) + } + } + } + + private fun configureWebView() { + binding?.webView?.let { webView -> + webView.webViewClient = ImportGoogleBookmarksWebFlowWebViewClient(this) + configureWebViewSettings(webView) + configureDownloadInterceptor(webView) + configureAutofill(webView) + + lifecycleScope.launch { + configureBookmarkImportJavascript(webView) + } + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun configureWebViewSettings(webView: WebView) { + webView.settings.apply { + userAgentString = userAgentProvider.userAgent() + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + setSupportMultipleWindows(true) + setSupportZoom(true) + } + } + + private fun configureDownloadInterceptor(webView: WebView) { + webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> + logcat { "Download intercepted: $url, mimeType: $mimeType, contentDisposition: $contentDisposition" } + + val folderName = context?.getString(R.string.autofillImportBookmarksChromeFolderName) ?: return@setDownloadListener + viewModel.onDownloadDetected( + url = url, + userAgent = userAgent, + contentDisposition = contentDisposition, + mimeType = mimeType, + folderName = folderName, + ) + } + } + + private fun configureAutofill(it: WebView) { + lifecycleScope.launch { + browserAutofill.addJsInterface( + it, + this@ImportGoogleBookmarksWebFlowFragment, + this@ImportGoogleBookmarksWebFlowFragment, + this@ImportGoogleBookmarksWebFlowFragment, + CUSTOM_FLOW_TAB_ID, + ) + } + + autofillFragmentResultListeners.getPlugins().forEach { plugin -> + setFragmentResultListener(plugin.resultKey(CUSTOM_FLOW_TAB_ID)) { _, result -> + context?.let { ctx -> + lifecycleScope.launch { + plugin.processResult( + result = result, + context = ctx, + tabId = CUSTOM_FLOW_TAB_ID, + fragment = this@ImportGoogleBookmarksWebFlowFragment, + autofillCallback = this@ImportGoogleBookmarksWebFlowFragment, + webView = binding?.webView, + ) + } + } + } + } + } + + @SuppressLint("RequiresFeature", "AddDocumentStartJavaScriptUsage") + private suspend fun configureBookmarkImportJavascript(webView: WebView) { + if (importBookmarkConfig.getConfig().canInjectJavascript) { + val script = googleImporterScriptLoader.getScriptForBookmarkImport() + WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) + } else { + logcat(WARN) { "Bookmark-import: Not able to inject bookmark import JavaScript" } + } + } + + private fun initialiseToolbar() { + with(getToolbar()) { + title = getString(R.string.autofillManagementImportBookmarks) + setNavigationIconAsCross() + setNavigationOnClickListener { viewModel.onCloseButtonPressed() } + } + } + + private fun Toolbar.setNavigationIconAsCross() { + setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_close_24) + } + + private fun getToolbar() = (activity as ImportGoogleBookmarksWebFlowActivity).binding.includeToolbar.toolbar + + override fun onPageStarted(url: String?) { + lifecycleScope.launch(dispatchers.main()) { + binding?.let { + val reauthDetails = url?.let { viewModel.getReauthData(url) } ?: ReAuthenticationDetails() + browserAutofillConfigurator.configureAutofillForCurrentPage(it.webView, url, reauthDetails) + } + } + } + + private fun configureBackButtonHandler() { + val onBackPressedCallback = + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val canGoBack = binding?.webView?.canGoBack() ?: false + viewModel.onBackButtonPressed(canGoBack) + } + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) + } + + private fun exitFlowAsSuccess(importedCount: Int = 0) { + val result = + Bundle().apply { + putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Success(importedCount)) + } + setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result) + } + + private fun exitFlowAsCancellation(stage: String) { + val result = + Bundle().apply { + putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.UserCancelled(stage)) + } + setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result) + } + + private fun exitFlowAsError(reason: UserCannotImportReason) { + val result = + Bundle().apply { + putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Error(reason)) + } + setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result) + } + + private suspend fun showCredentialChooserDialog( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + ) { + withContext(dispatchers.main()) { + val url = binding?.webView?.url ?: return@withContext + if (url != originalUrl) { + logcat(WARN) { "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 onCredentialsAvailableToInject( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + ) { + viewModel.onStoredCredentialsAvailable(originalUrl, credentials, triggerType, scenarioAllowsReAuthentication = false) + } + + override suspend fun onCredentialsAvailableToInjectWithReauth( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + requestSubType: SupportedAutofillInputSubType, + ) { + val reauthAllowed = requestSubType == PASSWORD && triggerType == AUTOPROMPT + viewModel.onStoredCredentialsAvailable(originalUrl, credentials, triggerType, reauthAllowed) + } + + override fun noCredentialsAvailable(originalUrl: String) { + viewModel.onNoStoredCredentialsAvailable(originalUrl) + } + + override suspend fun promptUserToImportPassword(originalUrl: String) { + logcat { "Autofill-import: we don't prompt the user to import in this flow" } + viewModel.onNoStoredCredentialsAvailable(originalUrl) + } + + override suspend fun onCredentialsAvailableToSave( + currentUrl: String, + credentials: LoginCredentials, + ) { + viewModel.onCredentialsAvailableToSave(currentUrl, credentials) + } + + override fun onShareCredentialsForAutofill( + originalUrl: String, + selectedCredentials: LoginCredentials, + ) { + if (binding?.webView?.url != originalUrl) { + logcat(WARN) { "WebView url has changed since autofill request; bailing" } + return + } + + browserAutofill.injectCredentials(selectedCredentials) + viewModel.onCredentialsAutofilled(originalUrl, selectedCredentials.password) + } + + override fun onNoCredentialsChosenForAutofill(originalUrl: String) { + if (binding?.webView?.url != originalUrl) { + logcat(WARN) { "WebView url has changed since autofill request; bailing" } + return + } + browserAutofill.injectCredentials(null) + } + + override suspend fun onGeneratedPasswordAvailableToUse( + originalUrl: String, + username: String?, + generatedPassword: String, + ) { + // no-op, password generation not used in this flow + } + + override fun onCredentialsSaved(savedCredentials: LoginCredentials) { + // no-op + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModel.kt new file mode 100644 index 000000000000..b172d91a47f6 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModel.kt @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2025 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.takeout.webflow + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType +import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor +import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore +import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails +import com.duckduckgo.autofill.impl.store.ReauthenticationHandler +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.logcat +import javax.inject.Inject + +@ContributesViewModel(FragmentScope::class) +class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( + private val dispatchers: DispatcherProvider, + private val reauthenticationHandler: ReauthenticationHandler, + private val autofillFeature: AutofillFeature, + private val bookmarkImportProcessor: BookmarkImportProcessor, + private val bookmarkImportConfigStore: BookmarkImportConfigStore, +) : ViewModel() { + private val _viewState = MutableStateFlow(ViewState.Initializing) + val viewState: StateFlow = _viewState + + private val _commands = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val commands: SharedFlow = _commands + + suspend fun loadInitialWebpage() { + withContext(dispatchers.io()) { + val initialUrl = bookmarkImportConfigStore.getConfig().launchUrlGoogleTakeout + _viewState.value = ViewState.LoadingWebPage(initialUrl) + } + } + + fun onDownloadDetected( + url: String, + userAgent: String, + contentDisposition: String?, + mimeType: String, + folderName: String, + ) { + viewModelScope.launch(dispatchers.io()) { + logcat { "Download detected: $url, mimeType: $mimeType, contentDisposition: $contentDisposition" } + + // Check if this looks like a valid Google Takeout bookmark export + val isValidImport = isTakeoutZipDownloadLink(mimeType, url, contentDisposition) + + if (isValidImport) { + logcat { "Valid bookmark import detected, starting download... $url" } + processBookmarkImport(url, userAgent, folderName) + } else { + logcat { "Invalid import type detected, exiting as failure. URL: $url" } + _commands.emit(Command.ExitFlowAsFailure(UserCannotImportReason.DownloadError)) + } + } + } + + private fun isTakeoutZipDownloadLink( + mimeType: String, + url: String, + contentDisposition: String?, + ): Boolean = + mimeType == "application/zip" || + mimeType == "application/octet-stream" || + url.contains(".zip") || + url.contains("takeout.google.com") || + contentDisposition?.contains("attachment") == true + + private suspend fun processBookmarkImport( + url: String, + userAgent: String, + folderName: String, + ) { + val importResult = bookmarkImportProcessor.downloadAndImportFromTakeoutZipUrl(url, userAgent, folderName) + handleImportResult(importResult, folderName) + } + + private suspend fun handleImportResult( + importResult: BookmarkImportProcessor.ImportResult, + folderName: String, + ) { + when (importResult) { + is BookmarkImportProcessor.ImportResult.Success -> { + logcat { "Successfully imported ${importResult.importedCount} bookmarks into '$folderName' folder" } + _commands.emit(Command.ExitFlowWithSuccess(importResult.importedCount)) + } + + is BookmarkImportProcessor.ImportResult.Error.DownloadError -> { + _commands.emit(Command.ExitFlowAsFailure(UserCannotImportReason.DownloadError)) + } + + is BookmarkImportProcessor.ImportResult.Error.ParseError -> { + _commands.emit(Command.ExitFlowAsFailure(UserCannotImportReason.ErrorParsingBookmarks)) + } + + is BookmarkImportProcessor.ImportResult.Error.ImportError -> { + _commands.emit(Command.ExitFlowAsFailure(UserCannotImportReason.ErrorParsingBookmarks)) + } + } + } + + fun onCloseButtonPressed() { + terminateFlowAsCancellation() + } + + fun onBackButtonPressed(canGoBack: Boolean = false) { + // 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() + return + } + + _viewState.value = ViewState.NavigatingBack + } + + private fun terminateFlowAsCancellation() { + viewModelScope.launch { + _viewState.value = ViewState.UserCancelledImportFlow(stage = "unknown") + } + } + + fun firstPageLoading() { + _viewState.value = ViewState.ShowWebPage + } + + suspend fun getReauthData(originalUrl: String): ReAuthenticationDetails? = + withContext(dispatchers.io()) { + if (canReAuthenticate()) { + reauthenticationHandler.retrieveReauthData(originalUrl) + } else { + null + } + } + + private suspend fun canReAuthenticate(): Boolean = + withContext(dispatchers.io()) { + autofillFeature.canReAuthenticateGoogleLoginsAutomatically().isEnabled() + } + + fun onStoredCredentialsAvailable( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + scenarioAllowsReAuthentication: Boolean, + ) { + viewModelScope.launch { + logcat { "Bookmark import - onStoredCredentialsAvailable. re-AuthAllowed=$scenarioAllowsReAuthentication, triggerType=$triggerType" } + val reauthData = if (scenarioAllowsReAuthentication) getReauthData(originalUrl) else null + if (reauthData?.password != null) { + logcat { "Stored credentials available but using re-authentication details instead: $reauthData" } + _commands.emit( + Command.InjectCredentialsFromReauth( + url = originalUrl, + password = reauthData.password, + ), + ) + } else { + logcat { "No re-auth data available or permitted, prompting user to select stored credentials" } + _commands.emit( + Command.PromptUserToSelectFromStoredCredentials( + originalUrl = originalUrl, + credentials = credentials, + triggerType = triggerType, + ), + ) + } + } + } + + fun onNoStoredCredentialsAvailable(originalUrl: String) { + viewModelScope.launch { + logcat { "Bookmark import - onNoStoredCredentialsAvailable for $originalUrl" } + _commands.emit(Command.NoCredentialsAvailable) + } + } + + fun onCredentialsAutofilled( + originalUrl: String, + password: String?, + ) { + logcat { "Bookmark import - credentials autofilled for $originalUrl" } + reauthenticationHandler.storeForReauthentication(originalUrl, password) + } + + fun onCredentialsAvailableToSave( + currentUrl: String, + credentials: LoginCredentials, + ) { + logcat { "Bookmark import - credentials available to save for $currentUrl" } + // Store credentials for potential re-use during this flow + reauthenticationHandler.storeForReauthentication(currentUrl, credentials.password) + } + + sealed interface Command { + data class InjectCredentialsFromReauth( + val url: String? = null, + val username: String = "", + val password: String?, + ) : Command + + data class PromptUserToSelectFromStoredCredentials( + val originalUrl: String, + val credentials: List, + val triggerType: LoginTriggerType, + ) : Command + + data object NoCredentialsAvailable : Command + + data class ExitFlowWithSuccess( + val importedCount: Int, + ) : Command + + data class ExitFlowAsFailure( + val reason: UserCannotImportReason, + ) : Command + } + + sealed interface ViewState { + data object Initializing : ViewState + + data object ShowWebPage : ViewState + + data class LoadingWebPage( + val url: String, + ) : ViewState + + data class UserCancelledImportFlow( + val stage: String, + ) : ViewState + + data class UserFinishedCannotImport( + val reason: UserCannotImportReason, + ) : ViewState + + data object NavigatingBack : ViewState + + data class ShowError( + val reason: UserCannotImportReason, + ) : ViewState + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowWebViewClient.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowWebViewClient.kt new file mode 100644 index 000000000000..b4b26fdeaec1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowWebViewClient.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 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.takeout.webflow + +import android.graphics.Bitmap +import android.webkit.WebView +import android.webkit.WebViewClient +import javax.inject.Inject + +class ImportGoogleBookmarksWebFlowWebViewClient @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/takeout/zip/TakeoutBookmarkExtractor.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/TakeoutBookmarkExtractor.kt index 5e44e2ddfb02..f8a70b9a6631 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/TakeoutBookmarkExtractor.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/TakeoutBookmarkExtractor.kt @@ -19,56 +19,55 @@ package com.duckduckgo.autofill.impl.importing.takeout.zip import android.content.Context import android.net.Uri import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult -import com.duckduckgo.autofill.impl.importing.takeout.zip.ZipEntryContentReader.ReadResult import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import javax.inject.Inject import kotlinx.coroutines.withContext import logcat.LogPriority.WARN import logcat.logcat +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import javax.inject.Inject interface TakeoutBookmarkExtractor { - sealed class ExtractionResult { - data class Success(val bookmarkHtmlContent: String) : ExtractionResult() { - override fun toString(): String { - return "ExtractionResult=success" - } + data class Success( + val tempFileUri: Uri, + ) : ExtractionResult() { + override fun toString(): String = "ExtractionResult=success" } - data class Error(val exception: Exception) : ExtractionResult() + + data class Error( + val exception: Exception, + ) : ExtractionResult() } /** - * Extracts the bookmark HTML content from the provided Google Takeout ZIP file URI. - * @param fileUri The URI of the Google Takeout ZIP file containing the bookmarks. - * @return ExtractionResult containing either the bookmark HTML content or an error. + * Extracts bookmarks from a Google Takeout ZIP file stored on disk. + * @param takeoutZipUri The URI of the Google Takeout ZIP file containing the bookmarks. + * @return ExtractionResult containing either a temp file URI with bookmark HTML content or an error. */ - suspend fun extractBookmarksHtml(fileUri: Uri): ExtractionResult + suspend fun extractBookmarksFromFile(takeoutZipUri: Uri): ExtractionResult } @ContributesBinding(AppScope::class) class TakeoutZipBookmarkExtractor @Inject constructor( private val context: Context, private val dispatchers: DispatcherProvider, - private val zipEntryContentReader: ZipEntryContentReader, ) : TakeoutBookmarkExtractor { - - override suspend fun extractBookmarksHtml(fileUri: Uri): ExtractionResult { - return withContext(dispatchers.io()) { + override suspend fun extractBookmarksFromFile(takeoutZipUri: Uri): ExtractionResult = + withContext(dispatchers.io()) { runCatching { - context.contentResolver.openInputStream(fileUri)?.use { inputStream -> + context.contentResolver.openInputStream(takeoutZipUri)?.use { inputStream -> ZipInputStream(inputStream).use { zipInputStream -> - processZipEntries(zipInputStream) + extractFromZipStreamToTempFile(zipInputStream) } - } ?: ExtractionResult.Error(Exception("Unable to open file: $fileUri")) + } ?: ExtractionResult.Error(Exception("Unable to open file: $takeoutZipUri")) }.getOrElse { ExtractionResult.Error(Exception(it)) } } - } - private fun processZipEntries(zipInputStream: ZipInputStream): ExtractionResult { + private fun extractFromZipStreamToTempFile(zipInputStream: ZipInputStream): ExtractionResult { var entry = zipInputStream.nextEntry if (entry == null) { @@ -81,10 +80,7 @@ class TakeoutZipBookmarkExtractor @Inject constructor( logcat { "Processing zip entry '$entryName'" } if (isBookmarkEntry(entry)) { - return when (val readResult = zipEntryContentReader.readAndValidateContent(zipInputStream, entryName)) { - is ReadResult.Success -> ExtractionResult.Success(readResult.content) - is ReadResult.Error -> ExtractionResult.Error(readResult.exception) - } + return streamEntryToTempFile(zipInputStream, entryName) } entry = zipInputStream.nextEntry @@ -93,11 +89,114 @@ class TakeoutZipBookmarkExtractor @Inject constructor( return ExtractionResult.Error(Exception("Chrome/Bookmarks.html not found in file")) } - private fun isBookmarkEntry(entry: ZipEntry): Boolean { - return !entry.isDirectory && entry.name.endsWith(EXPECTED_BOOKMARKS_FILENAME, ignoreCase = true) + private fun isBookmarkEntry(entry: ZipEntry): Boolean = !entry.isDirectory && entry.name.endsWith(EXPECTED_BOOKMARKS_FILENAME, ignoreCase = true) + + private fun streamEntryToTempFile( + zipInputStream: ZipInputStream, + entryName: String, + ): ExtractionResult { + cleanupOldTempFiles() + val tempFile = createTempFile() + + return try { + val totalBytesRead = streamAndValidateContent(zipInputStream, tempFile) + logcat { "Successfully streamed '$entryName' to temp file: ${tempFile.absolutePath}, size: $totalBytesRead bytes" } + ExtractionResult.Success(Uri.fromFile(tempFile)) + } catch (e: Exception) { + runCatching { tempFile.takeIf { it.exists() }?.delete() } + logcat(WARN) { "Error streaming ZIP entry to temp file: ${e.message}" } + ExtractionResult.Error(e) + } + } + + private fun createTempFile(): File = File.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX, context.cacheDir) + + private fun streamAndValidateContent( + zipInputStream: ZipInputStream, + tempFile: File, + ): Long { + var totalBytesRead = 0L + val buffer = ByteArray(BUFFER_SIZE) + val validator = ContentValidator() + + tempFile.outputStream().buffered().use { fileOutput -> + var bytesRead: Int + while (zipInputStream.read(buffer).also { bytesRead = it } != -1) { + totalBytesRead += bytesRead + + validator.processChunk(buffer, bytesRead) + + fileOutput.write(buffer, 0, bytesRead) + } + } + + if (!validator.isValid()) { + tempFile.delete() + throw Exception("File content is not a valid bookmark file") + } + + return totalBytesRead + } + + private inner class ContentValidator { + private val validationBuffer = StringBuilder() + private var validationResult: Boolean? = null + + fun processChunk( + buffer: ByteArray, + bytesRead: Int, + ) { + if (validationResult == null && validationBuffer.length < VALIDATION_BUFFER_MAX_SIZE) { + val chunkText = String(buffer, 0, bytesRead, Charsets.UTF_8) + validationBuffer.append(chunkText) + + if (validationBuffer.length >= VALIDATION_CONTENT_MIN_SIZE) { + val content = validationBuffer.toString() + validationResult = isValidBookmarkContent(content) + validationBuffer.clear() // Free memory after validation + } + } + } + + fun isValid(): Boolean = + validationResult ?: run { + val content = validationBuffer.toString() + isValidBookmarkContent(content) + } + } + + private fun isValidBookmarkContent(content: String): Boolean { + val hasNetscapeHeader = content.contains(NETSCAPE_HEADER, ignoreCase = true) + val hasBookmarkTitle = content.contains(BOOKMARK_TITLE, ignoreCase = true) + + logcat { "Content validation: hasNetscapeHeader=$hasNetscapeHeader, hasBookmarkTitle=$hasBookmarkTitle" } + + return hasNetscapeHeader || hasBookmarkTitle + } + + private fun cleanupOldTempFiles() { + try { + context.cacheDir + .listFiles { file -> + file.name.startsWith(TEMP_FILE_PREFIX) && file.name.endsWith(TEMP_FILE_SUFFIX) + }?.forEach { file -> + if (file.delete()) { + logcat { "Cleaned up old temp file: ${file.name}" } + } + } + } catch (e: Exception) { + logcat(WARN) { "Error cleaning up old temp files: ${e.message}" } + } } companion object { private const val EXPECTED_BOOKMARKS_FILENAME = "Chrome/Bookmarks.html" + private const val BUFFER_SIZE = 8192 + private const val NETSCAPE_HEADER = "Bookmarks" + private const val VALIDATION_BUFFER_MAX_SIZE = 2048 + private const val VALIDATION_CONTENT_MIN_SIZE = 1024 + private const val TEMP_FILE_PREFIX = "takeout_bookmarks_" + private const val TEMP_FILE_SUFFIX = ".html" } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/TakeoutZipDownloader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/TakeoutZipDownloader.kt new file mode 100644 index 000000000000..3120c0434178 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/TakeoutZipDownloader.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 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.takeout.zip + +import android.content.Context +import android.net.Uri +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.cookies.api.CookieManagerProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy +import kotlinx.coroutines.withContext +import logcat.logcat +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.File +import java.io.IOException +import javax.inject.Inject +import javax.inject.Named + +interface TakeoutZipDownloader { + suspend fun downloadZip( + url: String, + userAgent: String, + ): Uri +} + +@ContributesBinding(AppScope::class) +class RealTakeoutZipDownloader @Inject constructor( + @Named("nonCaching") private val okHttpClient: Lazy, + private val dispatchers: DispatcherProvider, + private val context: Context, + private val cookieManagerProvider: CookieManagerProvider, +) : TakeoutZipDownloader { + override suspend fun downloadZip( + url: String, + userAgent: String, + ): Uri = + withContext(dispatchers.io()) { + logcat { "Bookmark-import: Starting Google Takeout zip download from: $url" } + + val tempFile = createTempZipFile() + val request = constructRequestBuilder(url, userAgent).build() + + okHttpClient.get().newCall(request).execute().use { response -> + if (response.isSuccessful) { + downloadToFile(response, tempFile) + Uri.fromFile(tempFile) + } else { + tempFile.delete() + throw IOException("Bookmark-import: Google Takeout zip download failed: HTTP ${response.code}: ${response.message}") + } + } + } + + private fun constructRequestBuilder( + url: String, + userAgent: String, + ): Request.Builder { + val requestBuilder = + Request + .Builder() + .url(url) + .addHeader(HEADER_USER_AGENT, userAgent) + + // Extract cookies from WebView's CookieManager for this URL + val cookieManager = cookieManagerProvider.get() + val cookies = cookieManager?.getCookie(url) + if (cookies != null) { + requestBuilder.addHeader(HEADER_COOKIE, cookies) + } + + return requestBuilder + } + + private fun createTempZipFile(): File = File.createTempFile(FILE_PREFIX, FILE_SUFFIX, context.cacheDir) + + private fun downloadToFile( + response: Response, + tempFile: File, + ) { + val responseBody = response.body ?: throw IOException("Google Takeout zip download failed: Empty response body") + + tempFile.outputStream().buffered().use { fileOutput -> + responseBody.byteStream().use { inputStream -> + inputStream.copyTo(fileOutput) + } + } + + logcat { "Bookmark-import: Downloaded zip to temp file: ${tempFile.absolutePath}, size: ${tempFile.length()} bytes" } + } + + companion object { + private const val FILE_PREFIX = "takeout_download_" + private const val FILE_SUFFIX = ".zip" + private const val HEADER_USER_AGENT = "User-Agent" + private const val HEADER_COOKIE = "Cookie" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/ZipEntryContentReader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/ZipEntryContentReader.kt deleted file mode 100644 index 489295999ab4..000000000000 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/ZipEntryContentReader.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2025 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.takeout.zip - -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import java.util.zip.ZipInputStream -import javax.inject.Inject -import logcat.logcat - -interface ZipEntryContentReader { - - sealed class ReadResult { - data class Success(val content: String) : ReadResult() - data class Error(val exception: Exception) : ReadResult() - } - - fun readAndValidateContent( - zipInputStream: ZipInputStream, - entryName: String, - ): ReadResult -} - -@ContributesBinding(AppScope::class) -class BookmarkZipEntryContentReader @Inject constructor() : ZipEntryContentReader { - - override fun readAndValidateContent( - zipInputStream: ZipInputStream, - entryName: String, - ): ZipEntryContentReader.ReadResult { - logcat { "Reading content from ZIP entry: '$entryName'" } - - return try { - val content = readContent(zipInputStream, entryName) - - if (isValidBookmarkContent(content)) { - logcat { "Content validation passed for: '$entryName'" } - ZipEntryContentReader.ReadResult.Success(content) - } else { - logcat { "Content validation failed for: '$entryName'" } - ZipEntryContentReader.ReadResult.Error( - Exception("File content is not a valid bookmark file"), - ) - } - } catch (e: Exception) { - logcat { "Error reading ZIP entry content: ${e.message}" } - ZipEntryContentReader.ReadResult.Error(e) - } - } - - private fun readContent(zipInputStream: ZipInputStream, entryName: String): String { - val content = zipInputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } - logcat { "Read content from '$entryName', length: ${content.length}" } - return content - } - - private fun isValidBookmarkContent(content: String): Boolean { - val hasNetscapeHeader = content.contains(NETSCAPE_HEADER, ignoreCase = true) - val hasBookmarkTitle = content.contains(BOOKMARK_TITLE, ignoreCase = true) - - logcat { "Content validation: hasNetscapeHeader=$hasNetscapeHeader, hasBookmarkTitle=$hasBookmarkTitle" } - - return hasNetscapeHeader || hasBookmarkTitle - } - - companion object { - private const val NETSCAPE_HEADER = "Bookmarks" - } -} diff --git a/autofill/autofill-impl/src/main/res/layout/activity_import_google_bookmarks_webflow.xml b/autofill/autofill-impl/src/main/res/layout/activity_import_google_bookmarks_webflow.xml new file mode 100644 index 000000000000..b24d9e3e46a9 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/activity_import_google_bookmarks_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_bookmarks_webflow.xml b/autofill/autofill-impl/src/main/res/layout/fragment_import_google_bookmarks_webflow.xml new file mode 100644 index 000000000000..8a8eb3c75dfd --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/fragment_import_google_bookmarks_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 9f5e1db5733f..a43882209833 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -20,4 +20,9 @@ Imported from Chrome + + Import Bookmarks + Import Your Bookmarks + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/TakeoutZipBookmarkExtractorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/TakeoutZipBookmarkExtractorTest.kt new file mode 100644 index 000000000000..0538330eef2c --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/TakeoutZipBookmarkExtractorTest.kt @@ -0,0 +1,210 @@ +package com.duckduckgo.autofill.impl.importing.gpm.webflow + +import android.content.Context +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor +import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult.Success +import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutZipBookmarkExtractor +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.FileUtilities +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(AndroidJUnit4::class) +class TakeoutZipBookmarkExtractorTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val mockContext = mock() + private val mockUri = mock() + private val testee = + TakeoutZipBookmarkExtractor( + context = mockContext, + dispatchers = coroutineTestRule.testDispatcherProvider, + ) + + @Before + fun setup() { + whenever(mockContext.contentResolver).thenReturn(mock()) + whenever(mockContext.cacheDir).thenReturn( + File.createTempFile("temp", "dir").apply { + delete() + mkdirs() + }, + ) + } + + @Test + fun whenValidZipWithBookmarksHtmlThenExtractionSucceeds() = + runTest { + val bookmarkContent = loadHtmlFile("valid_chrome_bookmarks_netscape") + val zipData = createZipWithEntry("Takeout/Chrome/Bookmarks.html", bookmarkContent) + mockFileUri(mockUri, zipData) + + val result = testee.extractBookmarksFromFile(mockUri) + + assertTrue(result is Success) + val tempFileUri = (result as Success).tempFileUri + val actualContent = File(tempFileUri.path!!).readText() + assertEquals(bookmarkContent, actualContent) + } + + @Test + fun whenZipContainsMultipleEntriesButOnlyOneBookmarkThenCorrectFileExtracted() = + runTest { + val bookmarkContent = loadHtmlFile("valid_chrome_bookmarks_netscape") + val zipData = + createZipWithMultipleEntries( + mapOf( + "Takeout/Gmail/contacts.csv" to "email data", + "Takeout/Chrome/History" to "history data", + "Takeout/Chrome/Bookmarks.html" to bookmarkContent, + "Takeout/YouTube/subscriptions.json" to "youtube data", + ), + ) + mockFileUri(mockUri, zipData) + + val result = testee.extractBookmarksFromFile(mockUri) + + assertTrue(result is Success) + val tempFileUri = (result as Success).tempFileUri + val actualContent = File(tempFileUri.path!!).readText() + assertEquals(bookmarkContent, actualContent) + } + + @Test + fun whenBookmarksFileFoundButContentInvalidThenExtractionFails() = + runTest { + val invalidContent = loadHtmlFile("invalid_bookmark_content") + val zipData = createZipWithEntry("Takeout/Chrome/Bookmarks.html", invalidContent) + mockFileUri(mockUri, zipData) + + val result = testee.extractBookmarksFromFile(mockUri) + + assertTrue(result is TakeoutBookmarkExtractor.ExtractionResult.Error) + } + + @Test + fun whenZipDoesNotContainBookmarksFileThenExtractionFails() = + runTest { + val zipData = createZipWithEntry("Takeout/Gmail/contacts.csv", "email data") + mockFileUri(mockUri, zipData) + + val result = testee.extractBookmarksFromFile(mockUri) + + assertTrue(result is TakeoutBookmarkExtractor.ExtractionResult.Error) + } + + @Test + fun whenEmptyZipThenExtractionFails() = + runTest { + val emptyZipData = createEmptyZip() + mockFileUri(mockUri, emptyZipData) + + val result = testee.extractBookmarksFromFile(mockUri) + + assertTrue(result is TakeoutBookmarkExtractor.ExtractionResult.Error) + } + + @Test + fun whenFileCannotBeOpenedThenExtractionFails() = + runTest { + whenever(mockContext.contentResolver.openInputStream(mockUri)).thenReturn(null) + + val result = testee.extractBookmarksFromFile(mockUri) + + assertTrue(result is TakeoutBookmarkExtractor.ExtractionResult.Error) + } + + @Test + fun whenBookmarkHtmlHasNetscapeHeaderThenValidationPasses() = + runTest { + val content = loadHtmlFile("valid_chrome_bookmarks_netscape") + val zipData = createZipWithEntry("Takeout/Chrome/Bookmarks.html", content) + mockFileUri(mockUri, zipData) + + val result = testee.extractBookmarksFromFile(mockUri) + + assertTrue(result is Success) + } + + @Test + fun whenBookmarkHtmlHasBookmarkTitleThenValidationPasses() = + runTest { + val content = loadHtmlFile("valid_chrome_bookmarks_title_only") + val zipData = createZipWithEntry("Takeout/Chrome/Bookmarks.html", content) + mockFileUri(mockUri, zipData) + + val result = testee.extractBookmarksFromFile(mockUri) + + assertTrue(result is Success) + } + + @Test + fun whenBookmarkHtmlContainsMixedValidAndInvalidBookmarksThenValidationStillPasses() = + runTest { + val content = loadHtmlFile("mixed_valid_invalid_bookmarks") + val zipData = createZipWithEntry("Takeout/Chrome/Bookmarks.html", content) + mockFileUri(mockUri, zipData) + + val result = testee.extractBookmarksFromFile(mockUri) + + // Should still pass validation because it has valid bookmark structure + assertTrue(result is Success) + val tempFileUri = (result as Success).tempFileUri + val actualContent = File(tempFileUri.path!!).readText() + assertEquals(content, actualContent) + } + + private fun loadHtmlFile(filename: String): String = + FileUtilities.loadText( + TakeoutZipBookmarkExtractorTest::class.java.classLoader!!, + "html/$filename.html", + ) + + private fun createZipWithEntry( + entryName: String, + content: String, + ): ByteArray = createZipWithMultipleEntries(mapOf(entryName to content)) + + private fun createZipWithMultipleEntries(entries: Map): ByteArray { + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zos -> + entries.forEach { (name, content) -> + val entry = ZipEntry(name) + zos.putNextEntry(entry) + zos.write(content.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + } + return baos.toByteArray() + } + + private fun createEmptyZip(): ByteArray { + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { /* empty zip */ } + return baos.toByteArray() + } + + private fun mockFileUri( + uri: Uri, + zipData: ByteArray, + ) { + val inputStream: InputStream = ByteArrayInputStream(zipData) + whenever(mockContext.contentResolver.openInputStream(uri)).thenReturn(inputStream) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/feature/BookmarkImportConfigStoreImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/feature/BookmarkImportConfigStoreImplTest.kt new file mode 100644 index 000000000000..9419523a57ff --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/feature/BookmarkImportConfigStoreImplTest.kt @@ -0,0 +1,107 @@ +package com.duckduckgo.autofill.impl.importing.takeout.feature + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStoreImpl +import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStoreImpl.Companion.JAVASCRIPT_CONFIG_DEFAULT +import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStoreImpl.Companion.LAUNCH_URL_DEFAULT +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.json.JSONObjectAdapter +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BookmarkImportConfigStoreImplTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() + private val adapter: JsonAdapter = moshi.adapter(Config::class.java) + + private val autofillFeature = FakeFeatureToggleFactory.create(AutofillFeature::class.java) + private val testee = + BookmarkImportConfigStoreImpl( + autofillFeature = autofillFeature, + dispatchers = coroutineTestRule.testDispatcherProvider, + moshi = moshi, + ) + + @Test + fun whenFeatureFlagEnabledThenCanImportGoogleTakeoutConfigIsEnabled() = + runTest { + configureFeature(true) + assertTrue(testee.getConfig().canImportFromGoogleTakeout) + } + + @Test + fun whenFeatureFlagDisabledThenCanImportGoogleTakeoutConfigIsDisabled() = + runTest { + configureFeature(false) + assertFalse(testee.getConfig().canImportFromGoogleTakeout) + } + + @Test + fun whenLaunchUrlNotSpecifiedInConfigThenDefaultUsed() = + runTest { + configureFeature(config = Config()) + assertEquals(LAUNCH_URL_DEFAULT, testee.getConfig().launchUrlGoogleTakeout) + } + + @Test + fun whenLaunchUrlSpecifiedInConfigThenOverridesDefault() = + runTest { + configureFeature(config = Config(launchUrl = "https://example.com")) + assertEquals("https://example.com", testee.getConfig().launchUrlGoogleTakeout) + } + + @Test + fun whenJavascriptConfigNotSpecifiedInConfigThenDefaultUsed() = + runTest { + configureFeature(config = Config()) + assertEquals(JAVASCRIPT_CONFIG_DEFAULT, testee.getConfig().javascriptConfigGoogleTakeout) + } + + @Test + fun whenJavascriptConfigSpecifiedInConfigThenOverridesDefault() = + runTest { + configureFeature( + config = + Config( + javascriptConfig = JavaScriptConfig(key = "value", domains = listOf("foo, bar")), + ), + ) + assertEquals("""{"domains":["foo, bar"],"key":"value"}""", testee.getConfig().javascriptConfigGoogleTakeout) + } + + @SuppressLint("DenyListedApi") + private fun configureFeature( + enabled: Boolean = true, + config: Config = Config(), + ) { + autofillFeature.canImportBookmarksFromGoogleTakeout().setRawStoredState( + State( + remoteEnableState = enabled, + settings = adapter.toJson(config), + ), + ) + } + + private data class Config( + val launchUrl: String? = null, + val canInjectJavascript: Boolean = true, + val javascriptConfig: JavaScriptConfig? = null, + ) + + private data class JavaScriptConfig( + val key: String, + val domains: List, + ) +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImportProcessorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImportProcessorTest.kt new file mode 100644 index 000000000000..30d3c0efad6c --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImportProcessorTest.kt @@ -0,0 +1,157 @@ +package com.duckduckgo.autofill.impl.importing.takeout.processor + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.* +import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Error.DownloadError +import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Error.ImportError +import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Error.ParseError +import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Success +import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor +import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult +import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutZipDownloader +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.savedsites.api.models.SavedSite +import com.duckduckgo.savedsites.api.models.SavedSite.Bookmark +import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class TakeoutBookmarkImportProcessorTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + private val mockZipDownloader: TakeoutZipDownloader = mock() + private val mockExtractor: TakeoutBookmarkExtractor = mock() + private val mockImporter: TakeoutBookmarkImporter = mock() + + private val testee = + TakeoutBookmarkImportProcessor( + dispatchers = coroutineTestRule.testDispatcherProvider, + takeoutZipDownloader = mockZipDownloader, + bookmarkExtractor = mockExtractor, + takeoutBookmarkImporter = mockImporter, + ) + + @Before + fun setup() = + runTest { + configureDownloadSuccessful() + configureExtractionSuccessful() + } + + @Test + fun whenImportSucceedsWithMultipleBookmarksThenReturnsSuccessResult() = + runTest { + configureImportSuccessful(multipleBookmarks()) + + val result = triggerImport() + + assertTrue(result is Success) + assertEquals(3, (result as Success).importedCount) + } + + @Test + fun whenImportSucceedsWithSingleBookmarkThenReturnsSuccessResult() = + runTest { + configureImportSuccessful(singleBookmark()) + + val result = triggerImport() + + assertTrue(result is Success) + assertEquals(1, (result as Success).importedCount) + } + + @Test + fun whenImportSucceedsWithEmptyListThenReturnsSuccessResult() = + runTest { + configureImportSuccessful(emptyBookmarkList()) + + val result = triggerImport() + + assertTrue(result is Success) + assertEquals(0, (result as Success).importedCount) + } + + @Test + fun whenDownloadFailsThenReturnsDownloadError() = + runTest { + configureDownloadFailure() + + val result = triggerImport() + + assertTrue(result is DownloadError) + } + + @Test + fun whenExtractionFailsThenReturnsParseError() = + runTest { + configureExtractionFailure() + + val result = triggerImport() + + assertTrue(result is ParseError) + } + + @Test + fun whenImportFailsThenReturnsImportError() = + runTest { + configureImportFailure() + + val result = triggerImport() + + assertTrue(result is ImportError) + } + + private suspend fun triggerImport(): ImportResult = testee.downloadAndImportFromTakeoutZipUrl("aUrl", "aUserAgent", "aFolder") + + private suspend fun configureDownloadSuccessful() { + val testUri = Uri.parse("file:///test/bookmarks.zip") + whenever(mockZipDownloader.downloadZip(any(), any())).thenReturn(testUri) + } + + private suspend fun configureDownloadFailure() { + doAnswer { throw Exception("Download failed") }.whenever(mockZipDownloader).downloadZip(any(), any()) + } + + private suspend fun configureExtractionSuccessful() { + val extractedUri = Uri.parse("file:///test/bookmarks.html") + val extractionResult = ExtractionResult.Success(extractedUri) + whenever(mockExtractor.extractBookmarksFromFile(any())).thenReturn(extractionResult) + } + + private suspend fun configureExtractionFailure() { + val extractionResult = ExtractionResult.Error(Exception("Extraction failed")) + whenever(mockExtractor.extractBookmarksFromFile(any())).thenReturn(extractionResult) + } + + private suspend fun configureImportSuccessful(bookmarks: List) { + val importResult = ImportSavedSitesResult.Success(bookmarks) + whenever(mockImporter.importBookmarks(any(), any())).thenReturn(importResult) + } + + private suspend fun configureImportFailure() { + val importResult = ImportSavedSitesResult.Error(Exception("Import failed")) + whenever(mockImporter.importBookmarks(any(), any())).thenReturn(importResult) + } + + private fun multipleBookmarks(): List = + listOf( + Bookmark("1", "Test Bookmark 1", "https://example1.com", lastModified = null), + Bookmark("2", "Test Bookmark 2", "https://example2.com", lastModified = null), + Bookmark("3", "Test Bookmark 3", "https://example3.com", lastModified = null), + ) + + private fun emptyBookmarkList(): List = emptyList() + + private fun singleBookmark(): List = listOf(Bookmark("1", "Only Bookmark", "https://example.com", lastModified = null)) +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporterTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporterTest.kt index 1e8c70bf8864..76a29a239e15 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporterTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporterTest.kt @@ -1,5 +1,6 @@ package com.duckduckgo.autofill.impl.importing.takeout.processor +import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities @@ -9,6 +10,7 @@ import com.duckduckgo.savedsites.api.service.SavedSitesImporter import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -18,80 +20,158 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.io.File @RunWith(AndroidJUnit4::class) class TakeoutBookmarkImporterTest { - @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val mockSavedSitesImporter = mock() private val successfulResultNoneImported = ImportSavedSitesResult.Success(emptyList()) - private val successfulResultWithImports = ImportSavedSitesResult.Success( - listOf( - SavedSite.Bookmark("1", "Title 1", "http://example1.com", lastModified = "2023-01-01"), - SavedSite.Bookmark("2", "Title 2", "http://example2.com", lastModified = "2023-01-01"), - ), - ) + private val successfulResultWithImports = + ImportSavedSitesResult.Success( + listOf( + SavedSite.Bookmark("1", "Title 1", "http://example1.com", lastModified = "2023-01-01"), + SavedSite.Bookmark("2", "Title 2", "http://example2.com", lastModified = "2023-01-01"), + ), + ) private val failedResult = ImportSavedSitesResult.Error(Exception()) private val testFolder = ImportFolder.Folder("Test Folder") - private val testee = RealTakeoutBookmarkImporter( - savedSitesImporter = mockSavedSitesImporter, - dispatchers = coroutineTestRule.testDispatcherProvider, - ) + private val testee = + RealTakeoutBookmarkImporter( + savedSitesImporter = mockSavedSitesImporter, + dispatchers = coroutineTestRule.testDispatcherProvider, + ) @Test - fun whenImportingToRootThenCallsSavedSitesImporterWithRoot() = runTest { - configureSuccessfulResult() - testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) - verify(mockSavedSitesImporter).import(any(), eq(ImportFolder.Root)) - } + fun whenImportingToRootThenCallsSavedSitesImporterWithRoot() = + runTest { + configureSuccessfulResult() + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) + + testee.importBookmarks(tempFileUri, ImportFolder.Root) + + verify(mockSavedSitesImporter).import(any(), eq(ImportFolder.Root)) + } @Test - fun whenImportingToFolderThenCallsSavedSitesImporterWithFolder() = runTest { - configureSuccessfulResult() - testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), testFolder) - verify(mockSavedSitesImporter).import(any(), eq(testFolder)) - } + fun whenImportingToRootThenCallsSavedSitesImporterWithTempFile() = + runTest { + configureSuccessfulResult() + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) + + testee.importBookmarks(tempFileUri, ImportFolder.Root) + + verify(mockSavedSitesImporter).import(eq(tempFileUri), any()) + } @Test - fun whenImportSucceedsWithMultipleImportsThenReturnsSuccessResult() = runTest { - configureResult(successfulResultWithImports) + fun whenImportingToFolderThenCallsSavedSitesImporterWithFolder() = + runTest { + configureSuccessfulResult() + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) - val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) + testee.importBookmarks(tempFileUri, testFolder) - assertTrue(result is ImportSavedSitesResult.Success) - assertEquals(2, (result as ImportSavedSitesResult.Success).savedSites.size) - } + verify(mockSavedSitesImporter).import(any(), eq(testFolder)) + } @Test - fun whenImportSucceedsWithNoImportsThenReturnsSuccessResult() = runTest { - configureResult(successfulResultNoneImported) + fun whenImportingToFolderThenCallsSavedSitesImporterWithTempFile() = + runTest { + configureSuccessfulResult() + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) - val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) + testee.importBookmarks(tempFileUri, testFolder) - assertTrue(result is ImportSavedSitesResult.Success) - assertEquals(0, (result as ImportSavedSitesResult.Success).savedSites.size) - } + verify(mockSavedSitesImporter).import(eq(tempFileUri), any()) + } @Test - fun whenImportFailsThenReturnsErrorResult() = runTest { - configureResult(failedResult) + fun whenImportSucceedsWithMultipleImportsThenReturnsSuccessResult() = + runTest { + configureResult(successfulResultWithImports) + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) - val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) + val result = testee.importBookmarks(tempFileUri, ImportFolder.Root) - assertTrue(result is ImportSavedSitesResult.Error) - } + assertTrue(result is ImportSavedSitesResult.Success) + assertEquals(2, (result as ImportSavedSitesResult.Success).savedSites.size) + } @Test - fun whenSavedSitesImporterThrowsExceptionThenReturnsErrorResult() = runTest { - whenever(mockSavedSitesImporter.import(any(), any())).thenThrow(RuntimeException("Unexpected error")) - val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) - assertTrue(result is ImportSavedSitesResult.Error) - } + fun whenImportSucceedsWithNoImportsThenReturnsSuccessResult() = + runTest { + configureResult(successfulResultNoneImported) + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) + + val result = testee.importBookmarks(tempFileUri, ImportFolder.Root) + + assertTrue(result is ImportSavedSitesResult.Success) + assertEquals(0, (result as ImportSavedSitesResult.Success).savedSites.size) + } + + @Test + fun whenImportFailsThenReturnsErrorResult() = + runTest { + configureResult(failedResult) + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) + + val result = testee.importBookmarks(tempFileUri, ImportFolder.Root) + + assertTrue(result is ImportSavedSitesResult.Error) + } + + @Test + fun whenSavedSitesImporterThrowsExceptionThenReturnsErrorResult() = + runTest { + whenever(mockSavedSitesImporter.import(any(), any())).thenThrow(RuntimeException("Unexpected error")) + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) + + val result = testee.importBookmarks(tempFileUri, ImportFolder.Root) + + assertTrue(result is ImportSavedSitesResult.Error) + } + + @Test + fun whenImportSucceedsThenTempFileIsDeleted() = + runTest { + configureSuccessfulResult() + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) + val tempFile = File(tempFileUri.path!!) + + assertTrue("Temp file should exist before import", tempFile.exists()) + testee.importBookmarks(tempFileUri, ImportFolder.Root) + assertFalse("Temp file should be deleted after import", tempFile.exists()) + } + + @Test + fun whenImportFailsThenTempFileIsStillDeleted() = + runTest { + configureResult(failedResult) + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) + val tempFile = File(tempFileUri.path!!) + + assertTrue("Temp file should exist before import", tempFile.exists()) + testee.importBookmarks(tempFileUri, ImportFolder.Root) + assertFalse("Temp file should be deleted even after failed import", tempFile.exists()) + } + + @Test + fun whenImportThrowsExceptionThenTempFileIsStillDeleted() = + runTest { + whenever(mockSavedSitesImporter.import(any(), any())).thenThrow(RuntimeException("Unexpected error")) + val tempFileUri = createTempFileWithContent(loadHtmlFile("valid_chrome_bookmarks_netscape")) + val tempFile = File(tempFileUri.path!!) + + assertTrue("Temp file should exist before import", tempFile.exists()) + testee.importBookmarks(tempFileUri, ImportFolder.Root) + assertFalse("Temp file should be deleted even when exception is thrown", tempFile.exists()) + } private suspend fun configureResult(result: ImportSavedSitesResult) { whenever(mockSavedSitesImporter.import(any(), any())).thenReturn(result) @@ -101,10 +181,14 @@ class TakeoutBookmarkImporterTest { whenever(mockSavedSitesImporter.import(any(), any())).thenReturn(successfulResultNoneImported) } - private fun loadHtmlFile(filename: String): String { - return FileUtilities.loadText( + private fun loadHtmlFile(filename: String): String = + FileUtilities.loadText( TakeoutBookmarkImporterTest::class.java.classLoader!!, "html/$filename.html", ) + + private fun createTempFileWithContent(content: String): Uri { + val tempFile = File.createTempFile("test_bookmarks", ".html").also { it.writeText(content) } + return Uri.fromFile(tempFile) } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModelTest.kt new file mode 100644 index 000000000000..f9bdf8ff2eba --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModelTest.kt @@ -0,0 +1,120 @@ +package com.duckduckgo.autofill.impl.importing.takeout.webflow + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.NavigatingBack +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.ShowWebPage +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.UserCancelledImportFlow +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +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.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class ImportGoogleBookmarksWebFlowViewModelTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val mockBookmarkImportProcessor: BookmarkImportProcessor = mock() + + private val testee = + ImportGoogleBookmarksWebFlowViewModel( + dispatchers = coroutineTestRule.testDispatcherProvider, + reauthenticationHandler = mock(), + autofillFeature = mock(), + bookmarkImportProcessor = mockBookmarkImportProcessor, + bookmarkImportConfigStore = mock(), + ) + + @Test + fun whenFirstPageLoadingThenShowWebPageState() = + runTest { + testee.firstPageLoading() + assertEquals(ShowWebPage, testee.viewState.value) + } + + @Test + fun whenBackButtonPressedAndCannotGoBackThenUserCancelledState() = + runTest { + testee.onBackButtonPressed(canGoBack = false) + assertTrue(testee.viewState.value is UserCancelledImportFlow) + } + + @Test + fun whenBackButtonPressedAndCanGoBackThenNavigatingBackState() = + runTest { + testee.onBackButtonPressed(canGoBack = true) + assertEquals(NavigatingBack, testee.viewState.value) + } + + @Test + fun whenDownloadDetectedWithValidZipThenProcessorCalled() = + runTest { + configureImportSuccessful() + + testee.commands.test { + triggerDownloadDetectedWithTakeoutZip() + awaitItem() + verify(mockBookmarkImportProcessor).downloadAndImportFromTakeoutZipUrl(any(), any(), any()) + } + } + + @Test + fun whenDownloadDetectedWithValidZipButFailsToDownloadThenExitFlowAsFailureCommandEmitted() = + runTest { + configureImportFailure() + + testee.commands.test { + triggerDownloadDetectedWithTakeoutZip() + awaitItem() as Command.ExitFlowAsFailure + } + } + + @Test + fun whenDownloadDetectedWithInvalidTypeThenExitFlowAsFailureCommandEmitted() = + runTest { + testee.commands.test { + triggerDownloadDetectedButNotATakeoutZip() + awaitItem() as Command.ExitFlowAsFailure + } + } + + private fun triggerDownloadDetectedWithTakeoutZip() { + testee.onDownloadDetected( + url = "https://example.com/valid-file.zip", + userAgent = "Mozilla/5.0", + contentDisposition = null, + mimeType = "application/zip", + folderName = "a folder name", + ) + } + + private fun triggerDownloadDetectedButNotATakeoutZip() { + testee.onDownloadDetected( + url = "https://example.com/image.jpg", + userAgent = "Mozilla/5.0", + contentDisposition = null, + mimeType = "image/jpeg", + folderName = "a folder name", + ) + } + + private suspend fun configureImportSuccessful() { + whenever(mockBookmarkImportProcessor.downloadAndImportFromTakeoutZipUrl(any(), any(), any())) + .thenReturn(BookmarkImportProcessor.ImportResult.Success(10)) + } + + private suspend fun configureImportFailure() { + whenever(mockBookmarkImportProcessor.downloadAndImportFromTakeoutZipUrl(any(), any(), any())) + .thenReturn(BookmarkImportProcessor.ImportResult.Error.DownloadError) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/zip/RealTakeoutZipDownloaderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/zip/RealTakeoutZipDownloaderTest.kt new file mode 100644 index 000000000000..595998d2edb6 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/zip/RealTakeoutZipDownloaderTest.kt @@ -0,0 +1,133 @@ +package com.duckduckgo.autofill.impl.importing.takeout.zip + +import android.content.Context +import android.webkit.CookieManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.cookies.api.CookieManagerProvider +import dagger.Lazy +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.io.File +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class RealTakeoutZipDownloaderTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + @get:Rule + val temporaryFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + private val mockOkHttpClient = mock() + private val mockCall = mock() + private val mockResponse = mock() + private val mockCookieManager = mock() + private val mockCookieManagerProvider = mock() + private val mockContext = mock() + private val lazyOkHttpClient = Lazy { mockOkHttpClient } + private lateinit var tempFile: File + + private val testee = + RealTakeoutZipDownloader( + okHttpClient = lazyOkHttpClient, + dispatchers = coroutineTestRule.testDispatcherProvider, + context = mockContext, + cookieManagerProvider = mockCookieManagerProvider, + ) + + companion object { + private const val TEST_URL = "https://takeout.google.com/download/test.zip" + private const val TEST_USER_AGENT = "TestAgent/1.0" + } + + @Before + fun setup() { + whenever(mockCookieManagerProvider.get()).thenReturn(mockCookieManager) + whenever(mockOkHttpClient.newCall(any())).thenReturn(mockCall) + whenever(mockCall.execute()).thenReturn(mockResponse) + + tempFile = temporaryFolder.newFile("test.zip") + whenever(mockContext.cacheDir).thenReturn(temporaryFolder.root) + } + + @Test + fun whenDownloadSucceedsThenReturnsFileUri() = + runTest { + val expectedResponseBody = "zip file content" + configureRequestWithCookies() + configureSuccessfulResponse(expectedResponseBody) + + val result = testee.downloadZip(TEST_URL, TEST_USER_AGENT) + val resultFile = File(result.path!!) + + assertEquals(expectedResponseBody, resultFile.readText()) + assertEquals("file", result.scheme) + } + + @Test(expected = IOException::class) + fun whenDownloadFailsWithHttpErrorThenThrowsIOException() = + runTest { + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(404) + whenever(mockResponse.message).thenReturn("Not Found") + testee.downloadZip(TEST_URL, TEST_USER_AGENT) + } + + @Test(expected = IOException::class) + fun whenResponseBodyIsNullThenThrowsIOException() = + runTest { + whenever(mockResponse.isSuccessful).thenReturn(true) + whenever(mockResponse.body).thenReturn(null) + testee.downloadZip(TEST_URL, TEST_USER_AGENT) + } + + @Test + fun whenNoCookiesAvailableThenStillMakesRequest() = + runTest { + val expectedResponseBody = "zip file content" + configureRequestWithNoCookies() + configureSuccessfulResponse(expectedResponseBody) + + val result = testee.downloadZip(TEST_URL, TEST_USER_AGENT) + val resultFile = File(result.path!!) + + assertEquals(expectedResponseBody, resultFile.readText()) + } + + @Test(expected = IOException::class) + fun whenExceptionThrownDuringExecuteThenPropagatesException() = + runTest { + val expectedException = IOException("Network error") + whenever(mockCall.execute()).thenThrow(expectedException) + testee.downloadZip(TEST_URL, TEST_USER_AGENT) + } + + @Suppress("SameParameterValue") + private fun configureSuccessfulResponse(responseBody: String) { + val expectedZipData = responseBody.toByteArray() + whenever(mockResponse.body).thenReturn(expectedZipData.toResponseBody("application/zip".toMediaType())) + whenever(mockResponse.isSuccessful).thenReturn(true) + } + + private fun configureRequestWithCookies() { + whenever(mockCookieManager.getCookie(TEST_URL)).thenReturn("session=abc123") + } + + private fun configureRequestWithNoCookies() { + whenever(mockCookieManager.getCookie(TEST_URL)).thenReturn(null) + } +} 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 594c17f325a5..cbf86b87f1bd 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 @@ -36,7 +36,6 @@ import com.duckduckgo.autofill.api.AutofillScreenLaunchSource.InternalDevSetting import com.duckduckgo.autofill.api.AutofillScreens.AutofillPasswordsManagementScreen import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.impl.R as autofillR import com.duckduckgo.autofill.impl.configuration.AutofillJavascriptEnvironmentConfiguration import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore import com.duckduckgo.autofill.impl.engagement.store.AutofillEngagementRepository @@ -55,6 +54,8 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordRe 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.importing.takeout.processor.TakeoutBookmarkImporter +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmark.AutofillImportViaGoogleTakeoutScreen +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore @@ -80,17 +81,17 @@ import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder import com.google.android.material.snackbar.Snackbar -import java.text.SimpleDateFormat -import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import logcat.LogPriority import logcat.logcat +import java.text.SimpleDateFormat +import javax.inject.Inject +import com.duckduckgo.autofill.impl.R as autofillR @InjectWith(ActivityScope::class) class AutofillInternalSettingsActivity : DuckDuckGoActivity() { - private val binding: ActivityAutofillInternalSettingsBinding by viewBinding() @Inject @@ -163,64 +164,66 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { // used to output duration of import private var importStartTime: Long = 0 - private val importCsvLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - val data: Intent? = result.data - val fileUrl = data?.data - - logcat { "onActivityResult for CSV file request. resultCode=${result.resultCode}. uri=$fileUrl" } - if (fileUrl != null) { - lifecycleScope.launch(dispatchers.io()) { - when (val parseResult = csvCredentialConverter.readCsv(fileUrl)) { - is CsvCredentialImportResult.Success -> { - importStartTime = System.currentTimeMillis() - - credentialImporter.import( - parseResult.loginCredentialsToImport, - parseResult.numberCredentialsInSource, - ) - observePasswordInputUpdates() - } + private val importCsvLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val data: Intent? = result.data + val fileUrl = data?.data + + logcat { "onActivityResult for CSV file request. resultCode=${result.resultCode}. uri=$fileUrl" } + if (fileUrl != null) { + lifecycleScope.launch(dispatchers.io()) { + when (val parseResult = csvCredentialConverter.readCsv(fileUrl)) { + is CsvCredentialImportResult.Success -> { + importStartTime = System.currentTimeMillis() + + credentialImporter.import( + parseResult.loginCredentialsToImport, + parseResult.numberCredentialsInSource, + ) + observePasswordInputUpdates() + } - is CsvCredentialImportResult.Error -> { - FAILED_IMPORT_GENERIC_ERROR.showSnackbar() + is CsvCredentialImportResult.Error -> { + FAILED_IMPORT_GENERIC_ERROR.showSnackbar() + } } } } } } - } - private val importBookmarksTakeoutZipLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - val data: Intent? = result.data - val fileUrl = data?.data + private val importBookmarksTakeoutZipLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val data: Intent? = result.data + val fileUrl = data?.data - logcat { "onActivityResult for bookmarks zip request. uri=$fileUrl" } - fileUrl?.let { file -> - lifecycleScope.launch(dispatchers.io()) { - // Extract HTML content from zip - val extractionResult = takeoutZipTakeoutBookmarkExtractor.extractBookmarksHtml(file) - logcat { "Bookmark extraction result: $extractionResult" } - - when (extractionResult) { - is ExtractionResult.Success -> onBookmarksExtracted(extractionResult) - is ExtractionResult.Error -> { - "Error extracting bookmarks".showSnackbar() - logcat(LogPriority.WARN) { "Error extracting bookmarks: ${extractionResult.exception.message}" } + logcat { "onActivityResult for bookmarks zip request. uri=$fileUrl" } + fileUrl?.let { file -> + lifecycleScope.launch(dispatchers.io()) { + val extractionResult = takeoutZipTakeoutBookmarkExtractor.extractBookmarksFromFile(file) + logcat { "Bookmark extraction result: $extractionResult" } + + when (extractionResult) { + is ExtractionResult.Success -> onBookmarksExtracted(extractionResult) + is ExtractionResult.Error -> { + "Error extracting bookmarks".showSnackbar() + logcat(LogPriority.WARN) { "Error extracting bookmarks: ${extractionResult.exception.message}" } + } } } } } } - } private suspend fun onBookmarksExtracted(extractionResult: ExtractionResult.Success) { when ( - val importResult = takeoutBookmarkImporter.importBookmarks( - htmlContent = extractionResult.bookmarkHtmlContent, - destination = ImportFolder.Folder(getString(autofillR.string.autofillImportBookmarksChromeFolderName)), - ) + val importResult = + takeoutBookmarkImporter.importBookmarks( + extractionResult.tempFileUri, + ImportFolder.Folder(getString(autofillR.string.autofillImportBookmarksChromeFolderName)), + ) ) { is ImportSavedSitesResult.Success -> { logcat { "Successfully imported ${importResult.savedSites.size} bookmarks" } @@ -234,38 +237,64 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } - private val importGooglePasswordsFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - logcat { "onActivityResult for Google Password Manager import flow. resultCode=${result.resultCode}" } + 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() - is Error -> FAILED_IMPORT_GENERIC_ERROR.showSnackbar() - is UserCancelled, null -> { + if (result.resultCode == Activity.RESULT_OK) { + result.data?.let { + when (IntentCompat.getParcelableExtra(it, RESULT_KEY_DETAILS, ImportGooglePasswordResult::class.java)) { + is Success -> observePasswordInputUpdates() + is Error -> FAILED_IMPORT_GENERIC_ERROR.showSnackbar() + is UserCancelled, null -> { + } } } } } - } - private fun observePasswordInputUpdates() { - passwordImportWatcher += lifecycleScope.launch { - credentialImporter.getImportStatus().collect { - when (it) { - is InProgress -> { - logcat { "import status: $it" } - } + private val importGoogleBookmarksFlowLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + logcat { "onActivityResult for Google Takeout bookmark import flow. resultCode=${result.resultCode}" } - is Finished -> { - passwordImportWatcher.cancel() - val duration = System.currentTimeMillis() - importStartTime - logcat { "Imported ${it.savedCredentials} passwords, skipped ${it.numberSkipped}. Took ${duration}ms" } - "Imported ${it.savedCredentials} passwords".showSnackbar() + if (result.resultCode == RESULT_OK) { + result.data?.let { intent -> + when ( + val bookmarkResult = + IntentCompat.getParcelableExtra( + intent, + ImportGoogleBookmarkResult.RESULT_KEY_DETAILS, + ImportGoogleBookmarkResult::class.java, + ) + ) { + is ImportGoogleBookmarkResult.Success -> { + "Successfully imported ${bookmarkResult.importedCount} bookmarks".showSnackbar() + } + is ImportGoogleBookmarkResult.Error -> "Failed to import bookmarks".showSnackbar() + is ImportGoogleBookmarkResult.UserCancelled, null -> {} } } } } + + private fun observePasswordInputUpdates() { + passwordImportWatcher += + lifecycleScope.launch { + credentialImporter.getImportStatus().collect { + when (it) { + is InProgress -> { + logcat { "import status: $it" } + } + + is Finished -> { + passwordImportWatcher.cancel() + val duration = System.currentTimeMillis() - importStartTime + logcat { "Imported ${it.savedCredentials} passwords, skipped ${it.numberSkipped}. Took ${duration}ms" } + "Imported ${it.savedCredentials} passwords".showSnackbar() + } + } + } + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -305,13 +334,12 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } - private fun Toggle.description(includeRawState: Boolean = false): String { - return if (includeRawState) { + private fun Toggle.description(includeRawState: Boolean = false): String = + if (includeRawState) { "${isEnabled()} ${getRawStoredState()}" } else { isEnabled().toString() } - } private fun refreshAutofillJsConfigSettings() { lifecycleScope.launch(dispatchers.io()) { @@ -348,6 +376,44 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + private fun configureImportBookmarksEventHandlers() { + binding.importBookmarksLaunchGoogleTakeoutWebpage.setClickListener { + lifecycleScope.launch(dispatchers.io()) { + val url = "https://takeout.google.com" + startActivity(browserNav.openInNewTab(this@AutofillInternalSettingsActivity, url)) + } + } + binding.importBookmarksLaunchGoogleTakeoutCustomFlow.setClickListener { + lifecycleScope.launch { + if (importGooglePasswordsCapabilityChecker.webViewCapableOfImporting()) { + try { + val intent = globalActivityStarter.startIntent(this@AutofillInternalSettingsActivity, AutofillImportViaGoogleTakeoutScreen) + importGoogleBookmarksFlowLauncher.launch(intent) + } catch (e: Exception) { + val message = "Error launching bookmark import flow: ${e.message}" + logcat { message } + Toast.makeText(this@AutofillInternalSettingsActivity, message, Toast.LENGTH_LONG).show() + } + } else { + Toast.makeText(this@AutofillInternalSettingsActivity, "WebView version not supported", Toast.LENGTH_SHORT).show() + } + } + } + + binding.importBookmarksImportTakeoutZip.setClickListener { + val intent = + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + importBookmarksTakeoutZipLauncher.launch(intent) + } + + binding.viewBookmarks.setClickListener { + globalActivityStarter.start(this, BrowserScreens.BookmarksScreenNoParams) + } + } + @SuppressLint("QueryPermissionsNeeded") private fun configureImportPasswordsEventHandlers() { binding.importPasswordsLaunchGooglePasswordWebpage.setClickListener { @@ -369,10 +435,11 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } binding.importPasswordsImportCsv.setClickListener { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - } + val intent = + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } importCsvLauncher.launch(intent) } @@ -389,22 +456,24 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { autofillStore.inBrowserImportPromoShownCount = 0 inBrowserImportPromoPreviousPromptsStore.clear() } - Toast.makeText( - this@AutofillInternalSettingsActivity, - getString(R.string.autofillDevSettingsResetGooglePasswordsImportFlagConfirmation), - Toast.LENGTH_SHORT, - ).show() + Toast + .makeText( + this@AutofillInternalSettingsActivity, + getString(R.string.autofillDevSettingsResetGooglePasswordsImportFlagConfirmation), + Toast.LENGTH_SHORT, + ).show() } binding.markPasswordsAsPreviouslyImportedButton.setClickListener { lifecycleScope.launch(dispatchers.io()) { autofillStore.hasEverImportedPasswords = true } - Toast.makeText( - this@AutofillInternalSettingsActivity, - getString(R.string.autofillDevSettingsSimulatePasswordsImportedConfirmation), - Toast.LENGTH_SHORT, - ).show() + Toast + .makeText( + this@AutofillInternalSettingsActivity, + getString(R.string.autofillDevSettingsSimulatePasswordsImportedConfirmation), + Toast.LENGTH_SHORT, + ).show() } } @@ -438,48 +507,49 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } - private fun configureNeverSavedSitesEventHandlers() = with(binding) { - numberNeverSavedSitesCount.setClickListener { - lifecycleScope.launch(dispatchers.io()) { - neverSavedSiteRepository.clearNeverSaveList() + private fun configureNeverSavedSitesEventHandlers() = + with(binding) { + numberNeverSavedSitesCount.setClickListener { + lifecycleScope.launch(dispatchers.io()) { + neverSavedSiteRepository.clearNeverSaveList() + } } - } - addSampleNeverSavedSiteButton.setClickListener { - lifecycleScope.launch(dispatchers.io()) { - // should only actually add one entry for all these attempts - neverSavedSiteRepository.addToNeverSaveList("https://fill.dev") - neverSavedSiteRepository.addToNeverSaveList("fill.dev") - neverSavedSiteRepository.addToNeverSaveList("foo.fill.dev") - neverSavedSiteRepository.addToNeverSaveList("fill.dev/?q=123") + addSampleNeverSavedSiteButton.setClickListener { + lifecycleScope.launch(dispatchers.io()) { + // should only actually add one entry for all these attempts + neverSavedSiteRepository.addToNeverSaveList("https://fill.dev") + neverSavedSiteRepository.addToNeverSaveList("fill.dev") + neverSavedSiteRepository.addToNeverSaveList("foo.fill.dev") + neverSavedSiteRepository.addToNeverSaveList("fill.dev/?q=123") + } } } - } - - private fun configureAutofillJsConfigEventHandlers() = with(binding) { - val options = listOf(R.string.autofillDevSettingsConfigDebugOptionProduction, R.string.autofillDevSettingsConfigDebugOptionDebug) - changeAutofillJsConfigButton.setClickListener { - RadioListAlertDialogBuilder(this@AutofillInternalSettingsActivity) - .setTitle(R.string.autofillDevSettingsConfigSectionTitle) - .setOptions(options) - .setPositiveButton(R.string.autofillDevSettingsOverrideMaxInstallDialogOkButtonText) - .setNegativeButton(R.string.autofillDevSettingsOverrideMaxInstallDialogCancelButtonText) - .addEventListener( - object : RadioListAlertDialogBuilder.EventListener() { - override fun onPositiveButtonClicked(selectedItem: Int) { - lifecycleScope.launch(dispatchers.io()) { - when (selectedItem) { - 1 -> autofillJavascriptEnvironmentConfiguration.useProductionConfig() - 2 -> autofillJavascriptEnvironmentConfiguration.useDebugConfig() + private fun configureAutofillJsConfigEventHandlers() = + with(binding) { + val options = listOf(R.string.autofillDevSettingsConfigDebugOptionProduction, R.string.autofillDevSettingsConfigDebugOptionDebug) + + changeAutofillJsConfigButton.setClickListener { + RadioListAlertDialogBuilder(this@AutofillInternalSettingsActivity) + .setTitle(R.string.autofillDevSettingsConfigSectionTitle) + .setOptions(options) + .setPositiveButton(R.string.autofillDevSettingsOverrideMaxInstallDialogOkButtonText) + .setNegativeButton(R.string.autofillDevSettingsOverrideMaxInstallDialogCancelButtonText) + .addEventListener( + object : RadioListAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked(selectedItem: Int) { + lifecycleScope.launch(dispatchers.io()) { + when (selectedItem) { + 1 -> autofillJavascriptEnvironmentConfiguration.useProductionConfig() + 2 -> autofillJavascriptEnvironmentConfiguration.useDebugConfig() + } + refreshAutofillJsConfigSettings() } - refreshAutofillJsConfigSettings() } - } - }, - ) - .show() + }, + ).show() + } } - } private fun configureLoginsUiEventHandlers() { binding.accessAutofillSystemSettingsButton.setOnClickListener { @@ -589,8 +659,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { onUserChoseToClearSavedLogins() } }, - ) - .show() + ).show() } private fun onUserChoseToClearSavedLogins() { @@ -636,20 +705,20 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } }, - ) - .show() + ).show() } lifecycleScope.launch(dispatchers.main()) { repeatOnLifecycle(Lifecycle.State.STARTED) { - emailManager.signedInFlow().collect() { signedIn -> + emailManager.signedInFlow().collect { signedIn -> binding.emailProtectionSignOutButton.isEnabled = signedIn - val text = if (signedIn) { - getString(R.string.autofillDevSettingsEmailProtectionSignedInAs, emailManager.getEmailAddress()) - } else { - getString(R.string.autofillDevSettingsEmailProtectionNotSignedIn) - } + val text = + if (signedIn) { + getString(R.string.autofillDevSettingsEmailProtectionSignedInAs, emailManager.getEmailAddress()) + } else { + getString(R.string.autofillDevSettingsEmailProtectionNotSignedIn) + } binding.emailProtectionSignOutButton.setSecondaryText(text) } @@ -662,11 +731,12 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { val installDays = inContextDataStore.getMaximumPermittedDaysSinceInstallation() withContext(dispatchers.main()) { - val formatted = when { - (installDays < 0) -> getString(R.string.autofillDevSettingsOverrideMaxInstalledDaysNeverShow) - (installDays == Int.MAX_VALUE) -> getString(R.string.autofillDevSettingsOverrideMaxInstalledDaysAlwaysShow) - else -> getString(R.string.autofillDevSettingsOverrideMaxInstalledDaysSetting, installDays) - } + val formatted = + when { + (installDays < 0) -> getString(R.string.autofillDevSettingsOverrideMaxInstalledDaysNeverShow) + (installDays == Int.MAX_VALUE) -> getString(R.string.autofillDevSettingsOverrideMaxInstalledDaysAlwaysShow) + else -> getString(R.string.autofillDevSettingsOverrideMaxInstalledDaysSetting, installDays) + } binding.configureDaysFromInstallValue.setPrimaryText(formatted) } } @@ -692,13 +762,12 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { Snackbar.make(binding.root, this, duration).show() } - private fun Context.daysInstalledOverrideOptions(): List> { - return listOf( + private fun Context.daysInstalledOverrideOptions(): List> = + listOf( Pair(getString(R.string.autofillDevSettingsOverrideMaxInstalledOptionNever), -1), Pair(getString(R.string.autofillDevSettingsOverrideMaxInstalledOptionNumberDays, 21), 21), Pair(getString(R.string.autofillDevSettingsOverrideMaxInstalledOptionAlways), Int.MAX_VALUE), ) - } private suspend fun List.save() { withContext(dispatchers.io()) { @@ -710,9 +779,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { domain: String = "fill.dev", username: String, password: String = "password-123", - ): LoginCredentials { - return LoginCredentials(username = username, password = password, domain = domain) - } + ): LoginCredentials = LoginCredentials(username = username, password = password, domain = domain) private fun clearGoogleCookies() { val cookieManager = CookieManager.getInstance() @@ -729,39 +796,17 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { Toast.makeText(this, R.string.autofillDevSettingsGoogleLogoutSuccess, Toast.LENGTH_SHORT).show() } - private fun configureImportBookmarksEventHandlers() { - binding.importBookmarksLaunchGoogleTakeoutWebpage.setClickListener { - lifecycleScope.launch(dispatchers.io()) { - val url = "https://takeout.google.com" - startActivity(browserNav.openInNewTab(this@AutofillInternalSettingsActivity, url)) - } - } - - binding.importBookmarksImportTakeoutZip.setClickListener { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - } - importBookmarksTakeoutZipLauncher.launch(intent) - } - - binding.viewBookmarks.setClickListener { - globalActivityStarter.start(this, BrowserScreens.BookmarksScreenNoParams) - } - } - companion object { - fun intent(context: Context): Intent { - return Intent(context, AutofillInternalSettingsActivity::class.java) - } + fun intent(context: Context): Intent = Intent(context, AutofillInternalSettingsActivity::class.java) private const val FAILED_IMPORT_GENERIC_ERROR = "Failed to import passwords due to an error" - private val sampleUrlList = listOf( - "fill.dev", - "duckduckgo.com", - "spreadprivacy.com", - "duck.com", - ) + private val sampleUrlList = + listOf( + "fill.dev", + "duckduckgo.com", + "spreadprivacy.com", + "duck.com", + ) } } 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 7ca474d25c62..23e3fdc2448c 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 @@ -99,6 +99,10 @@ app:primaryText="@string/autofillDevSettingsClearLogins" tools:secondaryText="@string/autofillDevSettingsClearLoginsSubtitle" /> + + - @@ -157,6 +160,12 @@ android:layout_height="wrap_content" app:primaryText="@string/autofillDevSettingsImportBookmarksTitle" /> + + - - Import Bookmarks Launch Google Takeout (normal tab) + Launch Bookmarks import flow Choose zip file (downloaded from Takeout) View Bookmarks + Clear Previous Google Imports interactions Tap to forget whether we\'ve previously imported, what we chose when previously prompted etc… Eligible to see Google Import promos again diff --git a/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyFeatureName.kt b/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyFeatureName.kt index ca0870311273..dc1834bbd537 100644 --- a/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyFeatureName.kt +++ b/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyFeatureName.kt @@ -17,7 +17,9 @@ package com.duckduckgo.privacy.config.api /** List of [PrivacyFeatureName] that belong to the Privacy Configuration */ -enum class PrivacyFeatureName(val value: String) { +enum class PrivacyFeatureName( + val value: String, +) { ContentBlockingFeatureName("contentBlocking"), GpcFeatureName("gpc"), HttpsFeatureName("https"),