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"),