From d33bcf2bcc469eb10474adc2357a03ff08622515 Mon Sep 17 00:00:00 2001 From: Craig Russell <1336281+CDRussell@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:27:30 +0100 Subject: [PATCH] Add UI for importing bookmarks from Google --- .../autofill/api/AutofillCredentialDialogs.kt | 20 +- .../takeout/webflow/ImportFinishedFragment.kt | 128 +++++++++++ .../webflow/ImportGoogleBookmarkResult.kt | 6 + ...okmarksAutomationInProgressViewFragment.kt | 47 ++++ .../ImportGoogleBookmarksWebFlowActivity.kt | 205 ++++++++++++++++-- .../ImportGoogleBookmarksWebFlowFragment.kt | 156 ++++++++++--- .../ImportGoogleBookmarksWebFlowViewModel.kt | 32 ++- ...portGoogleBookmarksWebFlowWebViewClient.kt | 14 +- ...mportFromGoogleBookmarksPreImportDialog.kt | 146 +++++++++++++ ...okmark_import_info_listitem_background.xml | 22 ++ .../res/drawable/bookmarks_import_128.xml | 62 ++++++ .../src/main/res/drawable/info_24.xml | 35 +++ .../src/main/res/drawable/info_solid_24.xml | 26 +++ ...bookmarks_from_google_preimport_dialog.xml | 94 ++++++++ .../fragment_import_bookmarks_progress.xml | 115 ++++++++++ .../fragment_import_bookmarks_result.xml | 102 +++++++++ .../src/main/res/values/donottranslate.xml | 38 +++- ...portGoogleBookmarksWebFlowViewModelTest.kt | 63 +++++- .../AutofillInternalSettingsActivity.kt | 118 +++++++--- .../activity_autofill_internal_settings.xml | 18 ++ .../src/main/res/values/donottranslate.xml | 3 + 21 files changed, 1353 insertions(+), 97 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportFinishedFragment.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksAutomationInProgressViewFragment.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importbookmark/google/preimport/ImportFromGoogleBookmarksPreImportDialog.kt create mode 100644 autofill/autofill-impl/src/main/res/drawable/autofill_bookmark_import_info_listitem_background.xml create mode 100644 autofill/autofill-impl/src/main/res/drawable/bookmarks_import_128.xml create mode 100644 autofill/autofill-impl/src/main/res/drawable/info_24.xml create mode 100644 autofill/autofill-impl/src/main/res/drawable/info_solid_24.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/content_import_bookmarks_from_google_preimport_dialog.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/fragment_import_bookmarks_progress.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/fragment_import_bookmarks_result.xml diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCredentialDialogs.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCredentialDialogs.kt index d252324947da..84de7dc67da1 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCredentialDialogs.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCredentialDialogs.kt @@ -68,8 +68,8 @@ interface CredentialSavePickerDialog { companion object { fun resultKeyUserChoseToSaveCredentials(tabId: String) = "${prefix(tabId, TAG)}/UserChoseToSave" - fun resultKeyShouldPromptToDisableAutofill(tabId: String) = - "${prefix(tabId, TAG)}/ShouldPromptToDisableAutofill" + + fun resultKeyShouldPromptToDisableAutofill(tabId: String) = "${prefix(tabId, TAG)}/ShouldPromptToDisableAutofill" const val TAG = "CredentialSavePickerDialog" const val KEY_URL = "url" @@ -253,15 +253,17 @@ interface CredentialAutofillDialogFactory { /** * Creates a dialog which prompts the user to import passwords from Google Passwords */ - fun autofillImportPasswordsPromoDialog(importSource: AutofillImportLaunchSource, tabId: String, url: String): DialogFragment + fun autofillImportPasswordsPromoDialog( + importSource: AutofillImportLaunchSource, + tabId: String, + url: String, + ): DialogFragment } private fun prefix( tabId: String, tag: String, -): String { - return "$tabId/$tag" -} +): String = "$tabId/$tag" @Parcelize enum class AutofillImportLaunchSource(val value: String) : Parcelable { @@ -273,3 +275,9 @@ enum class AutofillImportLaunchSource(val value: String) : Parcelable { Unknown("unknown"), MainAppSettings("settings"), } + +@Parcelize +enum class AutofillImportBookmarksLaunchSource(val value: String) : Parcelable { + Unknown("unknown"), + AutofillDevSettings("autofill_dev_settings"), +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportFinishedFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportFinishedFragment.kt new file mode 100644 index 000000000000..4f5f7b65f75e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportFinishedFragment.kt @@ -0,0 +1,128 @@ +/* + * 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.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.FragmentImportBookmarksResultBinding +import com.duckduckgo.common.ui.DuckDuckGoFragment +import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.show +import com.duckduckgo.di.scopes.FragmentScope + +@InjectWith(FragmentScope::class) +class ImportFinishedFragment : DuckDuckGoFragment() { + private var binding: FragmentImportBookmarksResultBinding? = null + private var onDoneCallback: (() -> Unit)? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + binding = FragmentImportBookmarksResultBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + setupUi() + setupToolbar() + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun setupUi() { + val success = arguments?.getBoolean(ARG_BOOKMARK_IMPORT_SUCCESS, false) ?: false + + if (success) { + setupUiForSuccess() + } else { + setupUiForFailure() + } + + binding?.doneButton?.setOnClickListener { + onDoneCallback?.invoke() + } + } + + private fun setupUiForSuccess() { + val bookmarkCount = arguments?.getInt(ARG_BOOKMARK_COUNT_SUCCESS, 0) ?: 0 + + binding?.run { + bookmarksImportResult.setPrimaryText(getString(R.string.importBookmarksFromGoogleSuccessBookmarksCount, bookmarkCount)) + bookmarksImportResult.setLeadingIconResource(com.duckduckgo.mobile.android.R.drawable.ic_check_green_24) + importResultTitle.text = getString(R.string.importBookmarksSuccessTitle) + secondaryErrorInfo.gone() + } + } + + private fun setupUiForFailure() { + val error = arguments?.getString(ARG_BOOKMARK_FAILURE_MESSAGE) ?: getString(R.string.importBookmarksErrorGenericMessage) + + binding?.run { + bookmarksImportResult.setPrimaryText(error) + bookmarksImportResult.setLeadingIconResource(R.drawable.ic_cross_recolorable_red_24) + importResultTitle.text = getString(R.string.importBookmarksErrorTitle) + secondaryErrorInfo.show() + } + } + + fun setOnDoneCallback(callback: () -> Unit) { + onDoneCallback = callback + } + + private fun setupToolbar() { + val toolbar = activity?.findViewById(com.duckduckgo.mobile.android.R.id.toolbar) + toolbar?.setNavigationOnClickListener { + onDoneCallback?.invoke() + } + } + + companion object { + private const val ARG_BOOKMARK_COUNT_SUCCESS = "bookmark_import_count_success" + private const val ARG_BOOKMARK_IMPORT_SUCCESS = "bookmark_import_success" + private const val ARG_BOOKMARK_FAILURE_MESSAGE = "bookmark_import_failure_message" + + fun newInstanceSuccess(bookmarksImported: Int): ImportFinishedFragment = ImportFinishedFragment().apply { + arguments = + Bundle().apply { + putInt(ARG_BOOKMARK_COUNT_SUCCESS, bookmarksImported) + putBoolean(ARG_BOOKMARK_IMPORT_SUCCESS, true) + } + } + + fun newInstanceFailure(message: String): ImportFinishedFragment = ImportFinishedFragment().apply { + arguments = + Bundle().apply { + putString(ARG_BOOKMARK_FAILURE_MESSAGE, message) + putBoolean(ARG_BOOKMARK_IMPORT_SUCCESS, false) + } + } + } +} 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 index 99f6e01b1241..85023337b85c 100644 --- 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 @@ -47,4 +47,10 @@ sealed interface UserCannotImportReason : Parcelable { @Parcelize data object DownloadError : UserCannotImportReason + + @Parcelize + data object Unknown : UserCannotImportReason + + @Parcelize + data object WebViewError : UserCannotImportReason } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksAutomationInProgressViewFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksAutomationInProgressViewFragment.kt new file mode 100644 index 000000000000..cc1b407c542b --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksAutomationInProgressViewFragment.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.os.Bundle +import android.view.View +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.FragmentImportBookmarksProgressBinding +import com.duckduckgo.common.ui.DuckDuckGoFragment +import com.duckduckgo.di.scopes.FragmentScope + +@InjectWith(FragmentScope::class) +class ImportGoogleBookmarksAutomationInProgressViewFragment : DuckDuckGoFragment(R.layout.fragment_import_bookmarks_progress) { + private var binding: FragmentImportBookmarksProgressBinding? = null + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + binding = FragmentImportBookmarksProgressBinding.bind(view) + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + companion object { + fun newInstance(): ImportGoogleBookmarksAutomationInProgressViewFragment = ImportGoogleBookmarksAutomationInProgressViewFragment() + } +} 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 index f0e90768ded7..c83e1ec6379f 100644 --- 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 @@ -18,38 +18,154 @@ package com.duckduckgo.autofill.impl.importing.takeout.webflow import android.content.Intent import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.BundleCompat +import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.api.AutofillImportBookmarksLaunchSource 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.autofill.impl.importing.takeout.webflow.ImportGoogleBookmark.AutofillImportViaGoogleTakeoutScreenResultError +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmark.AutofillImportViaGoogleTakeoutScreenResultSuccess +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmark.ImportViaGoogleTakeoutScreen +import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.Unknown 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 +import com.duckduckgo.navigation.api.getActivityParams +import kotlinx.parcelize.Parcelize +import logcat.logcat +import javax.inject.Inject @InjectWith(ActivityScope::class) @ContributeToActivityStarter(AutofillImportViaGoogleTakeoutScreen::class) -class ImportGoogleBookmarksWebFlowActivity : DuckDuckGoActivity() { +@ContributeToActivityStarter(AutofillImportViaGoogleTakeoutScreenResultSuccess::class) +@ContributeToActivityStarter(AutofillImportViaGoogleTakeoutScreenResultError::class) +class ImportGoogleBookmarksWebFlowActivity : + DuckDuckGoActivity(), + ImportGoogleBookmarksWebFlowFragment.WebViewVisibilityListener { + @Inject + lateinit var appBuildConfig: AppBuildConfig + val binding: ActivityImportGoogleBookmarksWebflowBinding by viewBinding() + private val launchSource: AutofillImportBookmarksLaunchSource by lazy { + intent.getActivityParams(ImportViaGoogleTakeoutScreen::class.java)?.launchSource + ?: AutofillImportBookmarksLaunchSource.Unknown + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) + configureToolbar() configureResultListeners() - launchImportFragment() + + // Check if we should show the results screen immediately + val successResult = intent.getActivityParams(AutofillImportViaGoogleTakeoutScreenResultSuccess::class.java) + val errorResult = intent.getActivityParams(AutofillImportViaGoogleTakeoutScreenResultError::class.java) + + when { + successResult != null -> showSuccessFragment(successResult.bookmarkCount) + errorResult != null -> showErrorFragment(errorResult.errorReason) + else -> launchWebFlow() + } + } + + private fun launchWebFlow() { + logcat { "Bookmark-import: Starting webflow" } + replaceFragment(ImportGoogleBookmarksWebFlowFragment()) + invalidateOptionsMenu() } - private fun launchImportFragment() { + private fun showSuccessFragment(bookmarkCount: Int) { + logcat { "Bookmark-import: Showing success fragment with $bookmarkCount bookmarks" } + val successFragment = ImportFinishedFragment.newInstanceSuccess(bookmarksImported = bookmarkCount) + + successFragment.setOnDoneCallback { + finishWithSuccess(bookmarkCount) + } + + replaceFragment(successFragment) + invalidateOptionsMenu() + } + + private fun showErrorFragment(errorReason: UserCannotImportReason) { + logcat { "Bookmark-import: Showing error fragment with reason: $errorReason" } + + val messageId = R.string.importBookmarksErrorGenericMessage + val errorFragment = ImportFinishedFragment.newInstanceFailure(getString(messageId)) + + errorFragment.setOnDoneCallback { + finishWithFailure(errorReason) + } + + replaceFragment(errorFragment) + invalidateOptionsMenu() + } + + private fun replaceFragment(fragment: Fragment) { supportFragmentManager.commit { - replace(R.id.fragment_container, ImportGoogleBookmarksWebFlowFragment()) + replace(R.id.fragment_container, fragment) } } private fun configureResultListeners() { supportFragmentManager.setFragmentResultListener(ImportGoogleBookmarkResult.Companion.RESULT_KEY, this) { _, result -> - exitWithResult(result) + val importResult = BundleCompat.getParcelable( + result, + ImportGoogleBookmarkResult.RESULT_KEY_DETAILS, + ImportGoogleBookmarkResult::class.java, + ) + handleWebFlowResult(importResult) + } + } + + private fun showProgressOverlay() { + val progressFragment = supportFragmentManager.findFragmentByTag(PROGRESS_OVERLAY_TAG) + if (progressFragment == null) { + supportFragmentManager + .beginTransaction() + .add(R.id.fragment_container, ImportGoogleBookmarksAutomationInProgressViewFragment.newInstance(), PROGRESS_OVERLAY_TAG) + .commit() + } + } + + private fun hideProgressOverlay() { + val progressFragment = supportFragmentManager.findFragmentByTag(PROGRESS_OVERLAY_TAG) + if (progressFragment != null) { + supportFragmentManager + .beginTransaction() + .remove(progressFragment) + .commit() + } + } + + private fun handleWebFlowResult(result: ImportGoogleBookmarkResult?) { + when (result) { + is ImportGoogleBookmarkResult.Success -> { + logcat { "Bookmark-import: ${javaClass.simpleName}, WebFlow succeeded with ${result.importedCount} bookmarks" } + showSuccessFragment(result.importedCount) + } + + is ImportGoogleBookmarkResult.UserCancelled -> { + logcat { "Bookmark-import: ${javaClass.simpleName}, User cancelled at ${result.stage}" } + exitUserCancelled(result.stage) + } + + is ImportGoogleBookmarkResult.Error -> { + logcat { "Bookmark-import: ${javaClass.simpleName}, Import failed with reason: ${result.reason}" } + showErrorFragment(result.reason) + } + + null -> { + logcat { "Bookmark-import: ${javaClass.simpleName}, Received null result" } + showErrorFragment(Unknown) + } } } @@ -58,20 +174,79 @@ class ImportGoogleBookmarksWebFlowActivity : DuckDuckGoActivity() { finish() } + private fun finishWithSuccess(bookmarkCount: Int) { + val result = Bundle().apply { + putParcelable( + ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, + ImportGoogleBookmarkResult.Success(bookmarkCount), + ) + } + exitWithResult(result) + } + + private fun finishWithFailure(reason: UserCannotImportReason) { + val result = Bundle().apply { + putParcelable( + ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, + ImportGoogleBookmarkResult.Error(reason), + ) + } + exitWithResult(result) + } + fun exitUserCancelled(stage: String) { - val result = - Bundle().apply { - putParcelable( - ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, - ImportGoogleBookmarkResult.UserCancelled(stage), - ) - } + val result = Bundle().apply { + putParcelable( + ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, + ImportGoogleBookmarkResult.UserCancelled(stage), + ) + } exitWithResult(result) } + + private fun configureToolbar() { + with(binding.includeToolbar.toolbar) { + setupToolbar(this) + setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_close_24) + } + supportActionBar?.setDisplayHomeAsUpEnabled(true) + setTitle("") + } + + override fun showLoadingState() { + showProgressOverlay() + } + + override fun hideLoadingState() { + hideProgressOverlay() + } + + companion object { + private const val PROGRESS_OVERLAY_TAG = "progress_overlay" + } } object ImportGoogleBookmark { - data object AutofillImportViaGoogleTakeoutScreen : ActivityParams { - private fun readResolve(): Any = AutofillImportViaGoogleTakeoutScreen - } + @Parcelize + sealed class ImportViaGoogleTakeoutScreen( + val launchSource: AutofillImportBookmarksLaunchSource, + ) : ActivityParams, + Parcelable + + @Parcelize + data class AutofillImportViaGoogleTakeoutScreen( + private val source: AutofillImportBookmarksLaunchSource, + ) : ImportViaGoogleTakeoutScreen(source) + + @Parcelize + data class AutofillImportViaGoogleTakeoutScreenResultSuccess( + private val source: AutofillImportBookmarksLaunchSource, + val bookmarkCount: Int, + ) : ImportViaGoogleTakeoutScreen(source) + + @Parcelize + data class AutofillImportViaGoogleTakeoutScreenResultError( + private val source: AutofillImportBookmarksLaunchSource, + val errorReason: UserCannotImportReason, + ) : ImportViaGoogleTakeoutScreen(source) } 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 index f0b5f001c7dc..11609eaaf58a 100644 --- 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 @@ -24,13 +24,13 @@ 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.lifecycle.withStarted import androidx.webkit.WebViewCompat import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin @@ -48,11 +48,14 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillE 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.ImportGoogleBookmarkResult.Success 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.PromptUserToConfirmFlowCancellation import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.PromptUserToSelectFromStoredCredentials +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.HideWebPage 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 @@ -60,10 +63,18 @@ import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookma 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.importing.takeout.webflow.UserCannotImportReason.DownloadError +import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.ErrorParsingBookmarks +import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.Unknown +import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.WebViewError 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.ui.view.button.ButtonType.DESTRUCTIVE +import com.duckduckgo.common.ui.view.button.ButtonType.GHOST_ALT +import com.duckduckgo.common.ui.view.dialog.DaxAlertDialog +import com.duckduckgo.common.ui.view.dialog.TextAlertDialogBuilder import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.FragmentViewModelFactory import com.duckduckgo.common.utils.plugins.PluginPoint @@ -84,7 +95,7 @@ class ImportGoogleBookmarksWebFlowFragment : NoOpEmailProtectionInContextSignupFlowListener, NoOpEmailProtectionUserPromptListener, NoOpAutofillEventListener, - ImportGoogleBookmarksWebFlowWebViewClient.NewPageCallback { + ImportGoogleBookmarksWebFlowWebViewClient.WebFlowCallback { @Inject lateinit var userAgentProvider: UserAgentProvider @@ -113,17 +124,12 @@ class ImportGoogleBookmarksWebFlowFragment : lateinit var browserAutofillConfigurator: InternalBrowserAutofillConfigurator private var binding: FragmentImportGoogleBookmarksWebflowBinding? = null + private var cancellationDialog: DaxAlertDialog? = 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?, @@ -141,18 +147,15 @@ class ImportGoogleBookmarksWebFlowFragment : 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 { @@ -173,7 +176,8 @@ class ImportGoogleBookmarksWebFlowFragment : is LoadingWebPage -> loadFirstWebpage(viewState.url) is NavigatingBack -> binding?.webView?.goBack() is Initializing -> {} - is ShowWebPage -> {} + is ShowWebPage -> hideImportProgressDialog() + is HideWebPage -> showImportProgressDialog() } }.launchIn(lifecycleScope) } @@ -197,8 +201,12 @@ class ImportGoogleBookmarksWebFlowFragment : command.credentials, command.triggerType, ) - is ExitFlowWithSuccess -> exitFlowAsSuccess(command.importedCount) + is ExitFlowWithSuccess -> { + logcat { "Bookmark-import: ExitFlowWithSuccess received with count: ${command.importedCount}" } + exitFlowAsSuccess(command.importedCount) + } is ExitFlowAsFailure -> exitFlowAsError(command.reason) + is PromptUserToConfirmFlowCancellation -> askUserToConfirmCancellation() } }.launchIn(lifecycleScope) } @@ -247,7 +255,6 @@ class ImportGoogleBookmarksWebFlowFragment : userAgentString = userAgentProvider.userAgent() javaScriptEnabled = true domStorageEnabled = true - databaseEnabled = true loadWithOverviewMode = true useWideViewPort = true builtInZoomControls = true @@ -314,19 +321,41 @@ class ImportGoogleBookmarksWebFlowFragment : private fun initialiseToolbar() { with(getToolbar()) { - title = getString(R.string.autofillManagementImportBookmarks) - setNavigationIconAsCross() - setNavigationOnClickListener { viewModel.onCloseButtonPressed() } + setNavigationOnClickListener { askUserToConfirmCancellation() } + } + } + + private fun askUserToConfirmCancellation() { + context?.let { + // dismiss any existing dialog before creating a new one + dismissCancellationDialog() + + cancellationDialog = TextAlertDialogBuilder(it) + .setTitle(R.string.importBookmarksFromGoogleCancelConfirmationDialogTitle) + .setMessage(R.string.importBookmarksFromGoogleCancelConfirmationDialogMessage) + .setPositiveButton(R.string.importBookmarksFromGoogleCancelConfirmationDialogCancelImport, DESTRUCTIVE) + .setNegativeButton(R.string.importBookmarksFromGoogleCancelConfirmationDialogContinue, GHOST_ALT) + .setCancellable(true) + .addEventListener( + object : TextAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked() { + cancellationDialog = null + viewModel.onCloseButtonPressed() + } + }, + ).build().also { dialog -> dialog.show() } } } - private fun Toolbar.setNavigationIconAsCross() { - setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_close_24) + private fun dismissCancellationDialog() { + cancellationDialog?.dismiss() + cancellationDialog = null } private fun getToolbar() = (activity as ImportGoogleBookmarksWebFlowActivity).binding.includeToolbar.toolbar override fun onPageStarted(url: String?) { + viewModel.onPageStarted(url) lifecycleScope.launch(dispatchers.main()) { binding?.let { val reauthDetails = url?.let { viewModel.getReauthData(url) } ?: ReAuthenticationDetails() @@ -335,6 +364,11 @@ class ImportGoogleBookmarksWebFlowFragment : } } + override fun onFatalWebViewError() { + logcat(WARN) { "Bookmark-import: Fatal WebView error received" } + exitFlowAsError(WebViewError) + } + private fun configureBackButtonHandler() { val onBackPressedCallback = object : OnBackPressedCallback(true) { @@ -346,28 +380,48 @@ class ImportGoogleBookmarksWebFlowFragment : requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) } - private fun exitFlowAsSuccess(importedCount: Int = 0) { - val result = - Bundle().apply { - putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Success(importedCount)) + private fun exitFlowAsSuccess(bookmarkCount: Int) { + logcat { "Bookmark-import: Reporting import success with bookmarkCount: $bookmarkCount" } + lifecycleScope.launch { + lifecycle.withStarted { + dismissCancellationDialog() + val result = Bundle().apply { + putParcelable(ImportGoogleBookmarkResult.RESULT_KEY_DETAILS, Success(bookmarkCount)) + } + setFragmentResult(ImportGoogleBookmarkResult.RESULT_KEY, result) } - setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result) + } } private fun exitFlowAsCancellation(stage: String) { - val result = - Bundle().apply { - putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.UserCancelled(stage)) + logcat { "Bookmark-import: Flow cancelled at stage: $stage" } + + lifecycleScope.launch { + lifecycle.withStarted { + dismissCancellationDialog() + + val result = + Bundle().apply { + putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.UserCancelled(stage)) + } + setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result) } - setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result) + } } private fun exitFlowAsError(reason: UserCannotImportReason) { - val result = - Bundle().apply { - putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Error(reason)) + logcat { "Bookmark-import: Flow error at stage: ${reason.mapToStage()}" } + + lifecycleScope.launch { + lifecycle.withStarted { + dismissCancellationDialog() + + val result = Bundle().apply { + putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Error(reason)) + } + setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result) } - setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result) + } } private suspend fun showCredentialChooserDialog( @@ -459,4 +513,38 @@ class ImportGoogleBookmarksWebFlowFragment : override fun onCredentialsSaved(savedCredentials: LoginCredentials) { // no-op } + + private fun showImportProgressDialog() { + logcat { "Bookmark-import: Notifying activity to show progress" } + (activity as? WebViewVisibilityListener)?.showLoadingState() + } + + private fun hideImportProgressDialog() { + logcat { "Bookmark-import: Notifying activity to hide progress" } + (activity as? WebViewVisibilityListener)?.hideLoadingState() + } + + override fun onDestroyView() { + dismissCancellationDialog() + binding = null + super.onDestroyView() + } + + interface WebViewVisibilityListener { + fun showLoadingState() + fun hideLoadingState() + } + + companion object { + private const val CUSTOM_FLOW_TAB_ID = "bookmark-import-webflow" + private const val SELECT_CREDENTIALS_FRAGMENT_TAG = "autofillSelectCredentialsDialog" + } } + +private fun UserCannotImportReason.mapToStage(): String = + when (this) { + DownloadError -> "zip-download-error" + ErrorParsingBookmarks -> "zip-parse-error" + Unknown -> "import-error-unknown" + WebViewError -> "webview-error" + } 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 index b172d91a47f6..be7606796faf 100644 --- 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 @@ -16,6 +16,7 @@ package com.duckduckgo.autofill.impl.importing.takeout.webflow +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel @@ -24,6 +25,9 @@ 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.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.PromptUserToConfirmFlowCancellation +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.HideWebPage +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.ShowWebPage import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails import com.duckduckgo.autofill.impl.store.ReauthenticationHandler import com.duckduckgo.common.utils.DispatcherProvider @@ -130,13 +134,12 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( } 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 + // if WebView can't go back, we should prompt user if they want to cancel the flow + viewModelScope.launch { _commands.emit(PromptUserToConfirmFlowCancellation) } + } else { + _viewState.value = ViewState.NavigatingBack } - - _viewState.value = ViewState.NavigatingBack } private fun terminateFlowAsCancellation() { @@ -146,7 +149,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( } fun firstPageLoading() { - _viewState.value = ViewState.ShowWebPage + _viewState.value = ShowWebPage } suspend fun getReauthData(originalUrl: String): ReAuthenticationDetails? = @@ -217,6 +220,19 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( reauthenticationHandler.storeForReauthentication(currentUrl, credentials.password) } + fun onPageStarted(url: String?) { + val host = url?.toUri()?.host ?: return + _viewState.value = if (host.contains(TAKEOUT_ADDRESS, ignoreCase = true)) { + HideWebPage + } else { + ShowWebPage + } + } + + private companion object { + private const val TAKEOUT_ADDRESS = "takeout.google.com" + } + sealed interface Command { data class InjectCredentialsFromReauth( val url: String? = null, @@ -230,6 +246,8 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( val triggerType: LoginTriggerType, ) : Command + data object PromptUserToConfirmFlowCancellation : Command + data object NoCredentialsAvailable : Command data class ExitFlowWithSuccess( @@ -246,6 +264,8 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( data object ShowWebPage : ViewState + data object HideWebPage : ViewState + data class LoadingWebPage( val url: String, ) : 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 index b4b26fdeaec1..c5204eb0f5ee 100644 --- 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 @@ -17,17 +17,19 @@ package com.duckduckgo.autofill.impl.importing.takeout.webflow import android.graphics.Bitmap +import android.webkit.RenderProcessGoneDetail import android.webkit.WebView import android.webkit.WebViewClient import javax.inject.Inject class ImportGoogleBookmarksWebFlowWebViewClient @Inject constructor( - private val callback: NewPageCallback, + private val callback: WebFlowCallback, ) : WebViewClient() { - interface NewPageCallback { + interface WebFlowCallback { fun onPageStarted(url: String?) {} fun onPageFinished(url: String?) {} + fun onFatalWebViewError() } override fun onPageStarted( @@ -44,4 +46,12 @@ class ImportGoogleBookmarksWebFlowWebViewClient @Inject constructor( ) { callback.onPageFinished(url) } + + override fun onRenderProcessGone( + view: WebView?, + detail: RenderProcessGoneDetail?, + ): Boolean { + callback.onFatalWebViewError() + return true + } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importbookmark/google/preimport/ImportFromGoogleBookmarksPreImportDialog.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importbookmark/google/preimport/ImportFromGoogleBookmarksPreImportDialog.kt new file mode 100644 index 000000000000..7f0aacef86b6 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importbookmark/google/preimport/ImportFromGoogleBookmarksPreImportDialog.kt @@ -0,0 +1,146 @@ +/* + * 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.ui.credential.management.importbookmark.google.preimport + +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.BundleCompat +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.autofill.api.AutofillImportBookmarksLaunchSource +import com.duckduckgo.autofill.api.AutofillImportBookmarksLaunchSource.Unknown +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.ContentImportBookmarksFromGooglePreimportDialogBinding +import com.duckduckgo.autofill.impl.ui.credential.dialog.animateClosed +import com.duckduckgo.autofill.impl.ui.credential.management.importbookmark.google.preimport.ImportFromGoogleBookmarksPreImportDialog.ImportBookmarksDialog.Companion.KEY_TAB_ID +import com.duckduckgo.common.utils.FragmentViewModelFactory +import com.duckduckgo.di.scopes.FragmentScope +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.android.support.AndroidSupportInjection +import logcat.LogPriority.VERBOSE +import logcat.logcat +import javax.inject.Inject + +@InjectWith(FragmentScope::class) +class ImportFromGoogleBookmarksPreImportDialog : BottomSheetDialogFragment() { + /** + * To capture all the ways the BottomSheet can be dismissed, we might end up with onCancel being called when we don't want it + * This flag is set to true when taking an action which dismisses the dialog, but should not be treated as a cancellation. + */ + private var ignoreCancellationEvents = false + + override fun getTheme(): Int = R.style.AutofillBottomSheetDialogTheme + + private var _binding: ContentImportBookmarksFromGooglePreimportDialogBinding? = null + + val binding get() = _binding!! + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + private var importClickedCallback: (() -> Unit)? = null + + fun setImportClickedCallback(callback: () -> Unit) { + importClickedCallback = callback + } + + private fun getLaunchSource() = + BundleCompat.getParcelable(arguments ?: Bundle(), KEY_LAUNCH_SOURCE, AutofillImportBookmarksLaunchSource::class.java) ?: Unknown + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState != null) { + // If being created after a configuration change, dismiss the dialog as the WebView will be re-created too + dismiss() + return + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = ContentImportBookmarksFromGooglePreimportDialogBinding.inflate(inflater, container, false) + configureViews(binding) + logcat { "Creating ${javaClass.simpleName} with launch source: ${getLaunchSource()}" } + return binding.root + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + private fun configureViews(binding: ContentImportBookmarksFromGooglePreimportDialogBinding) { + (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED + configureCloseButton(binding) + + with(binding.importButton) { + setOnClickListener { onImportButtonClicked() } + } + } + + private fun onImportButtonClicked() { + importClickedCallback?.invoke() + } + + override fun onCancel(dialog: DialogInterface) { + if (ignoreCancellationEvents) { + logcat(VERBOSE) { "onCancel: Ignoring cancellation event" } + return + } + } + + private fun configureCloseButton(binding: ContentImportBookmarksFromGooglePreimportDialogBinding) { + binding.closeButton.setOnClickListener { (dialog as BottomSheetDialog).animateClosed() } + } + + companion object { + private const val KEY_LAUNCH_SOURCE = "launchSource" + + fun instance( + importSource: AutofillImportBookmarksLaunchSource, + tabId: String? = null, + ): ImportFromGoogleBookmarksPreImportDialog { + val fragment = ImportFromGoogleBookmarksPreImportDialog() + fragment.arguments = + Bundle().apply { + putParcelable(KEY_LAUNCH_SOURCE, importSource) + putString(KEY_TAB_ID, tabId) + } + return fragment + } + } + + interface ImportBookmarksDialog { + companion object { + const val KEY_TAB_ID = "tabId" + } + } +} diff --git a/autofill/autofill-impl/src/main/res/drawable/autofill_bookmark_import_info_listitem_background.xml b/autofill/autofill-impl/src/main/res/drawable/autofill_bookmark_import_info_listitem_background.xml new file mode 100644 index 000000000000..ea56fcc27858 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/autofill_bookmark_import_info_listitem_background.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/drawable/bookmarks_import_128.xml b/autofill/autofill-impl/src/main/res/drawable/bookmarks_import_128.xml new file mode 100644 index 000000000000..213c2ba2d7e3 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/bookmarks_import_128.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/info_24.xml b/autofill/autofill-impl/src/main/res/drawable/info_24.xml new file mode 100644 index 000000000000..20883c8223e8 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/info_24.xml @@ -0,0 +1,35 @@ + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/info_solid_24.xml b/autofill/autofill-impl/src/main/res/drawable/info_solid_24.xml new file mode 100644 index 000000000000..ae0f2d80eda4 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/info_solid_24.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_bookmarks_from_google_preimport_dialog.xml b/autofill/autofill-impl/src/main/res/layout/content_import_bookmarks_from_google_preimport_dialog.xml new file mode 100644 index 000000000000..eafe18fe2283 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_bookmarks_from_google_preimport_dialog.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/layout/fragment_import_bookmarks_progress.xml b/autofill/autofill-impl/src/main/res/layout/fragment_import_bookmarks_progress.xml new file mode 100644 index 000000000000..d7e3bae9b3be --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/fragment_import_bookmarks_progress.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/layout/fragment_import_bookmarks_result.xml b/autofill/autofill-impl/src/main/res/layout/fragment_import_bookmarks_result.xml new file mode 100644 index 000000000000..fdf9f6c8c4a0 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/fragment_import_bookmarks_result.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/values/donottranslate.xml b/autofill/autofill-impl/src/main/res/values/donottranslate.xml index a43882209833..fee213f984a9 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -15,14 +15,46 @@ --> + Import to DuckDuckGo Imported from Chrome - - - Import Bookmarks Import Your Bookmarks + Import Bookmarks From Google + Google may ask you to sign in or enter your password to confirm. + Import From Google + + @string/importToDuckDuckGo + Your bookmarks are being imported. This may take a few moments. + Cancel Import + + Import Bookmarks + + + Importing Bookmarks… + Google may ask you to sign in or enter your password to confirm. + Google may send you an email confirming your bookmarks are being exported. + Importing bookmarks… + Import bookmarks icon + + Cancel import? + Your bookmarks won\'t be imported if you cancel now. + Cancel Import + Continue + + Bookmarks: %1$d + Something went wrong. You can try again. + + + @string/importToDuckDuckGo + Close + Import success icon + Bookmark icon + + @string/importToDuckDuckGo + Something went wrong + Please make sure you’re connected to the internet and try again. \ No newline at end of file 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 index f9bdf8ff2eba..a50072e02b9d 100644 --- 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 @@ -4,9 +4,10 @@ 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.Command.PromptUserToConfirmFlowCancellation +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.HideWebPage 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 @@ -35,6 +36,57 @@ class ImportGoogleBookmarksWebFlowViewModelTest { bookmarkImportConfigStore = mock(), ) + @Test + fun whenOnPageStartedWithTakeoutUrlThenHideWebPage() = + runTest { + testee.onPageStarted("https://takeout.google.com") + assertEquals(HideWebPage, testee.viewState.value) + } + + @Test + fun whenOnPageStartedWithUppercaseTakeoutUrlThenHideWebPage() = + runTest { + testee.onPageStarted("https://TAKEOUT.GOOGLE.COM") + assertEquals(HideWebPage, testee.viewState.value) + } + + @Test + fun whenOnPageStartedWithAccountsGoogleUrlThenShowWebPage() = + runTest { + testee.onPageStarted("https://accounts.google.com/signin") + assertEquals(ShowWebPage, testee.viewState.value) + } + + @Test + fun whenOnPageStartedWithExampleUrlThenShowWebPage() = + runTest { + testee.onPageStarted("https://example.com/page") + assertEquals(ShowWebPage, testee.viewState.value) + } + + @Test + fun whenOnPageStartedWithAccountsGoogleUrlContainingTakeoutInPathThenShowWebPage() = + runTest { + testee.onPageStarted("https://accounts.google.com/signin?continue=https://takeout.google.com") + assertEquals(ShowWebPage, testee.viewState.value) + } + + @Test + fun whenOnPageStartedWithNullUrlThenViewStateRemainsUnchanged() = + runTest { + val initialState = testee.viewState.value + testee.onPageStarted(null) + assertEquals(initialState, testee.viewState.value) + } + + @Test + fun whenOnPageStartedWithMalformedUrlThenViewStateRemainsUnchanged() = + runTest { + val initialState = testee.viewState.value + testee.onPageStarted("not-a-valid-url") + assertEquals(initialState, testee.viewState.value) + } + @Test fun whenFirstPageLoadingThenShowWebPageState() = runTest { @@ -43,10 +95,13 @@ class ImportGoogleBookmarksWebFlowViewModelTest { } @Test - fun whenBackButtonPressedAndCannotGoBackThenUserCancelledState() = + fun whenBackButtonPressedAndCannotGoBackThenPromptUserToConfirmFlowCancellation() = runTest { - testee.onBackButtonPressed(canGoBack = false) - assertTrue(testee.viewState.value is UserCancelledImportFlow) + testee.commands.test { + testee.onBackButtonPressed(canGoBack = false) + val command = awaitItem() + assertTrue(command is PromptUserToConfirmFlowCancellation) + } } @Test 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 cbf86b87f1bd..9e0e2a608c19 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 @@ -17,7 +17,6 @@ package com.duckduckgo.autofill.internal import android.annotation.SuppressLint -import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle @@ -32,6 +31,7 @@ import androidx.lifecycle.repeatOnLifecycle import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.autofill.api.AutofillImportBookmarksLaunchSource.AutofillDevSettings import com.duckduckgo.autofill.api.AutofillScreenLaunchSource.InternalDevSettings import com.duckduckgo.autofill.api.AutofillScreens.AutofillPasswordsManagementScreen import com.duckduckgo.autofill.api.domain.app.LoginCredentials @@ -55,12 +55,19 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordRe 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.ImportGoogleBookmark.AutofillImportViaGoogleTakeoutScreenResultError +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmark.AutofillImportViaGoogleTakeoutScreenResultSuccess import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult +import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.DownloadError +import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.ErrorParsingBookmarks +import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.Unknown +import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.WebViewError 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 import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository +import com.duckduckgo.autofill.impl.ui.credential.management.importbookmark.google.preimport.ImportFromGoogleBookmarksPreImportDialog import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurveyStore import com.duckduckgo.autofill.internal.databinding.ActivityAutofillInternalSettingsBinding import com.duckduckgo.autofill.store.AutofillPrefsStore @@ -166,7 +173,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { private val importCsvLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { + if (result.resultCode == RESULT_OK) { val data: Intent? = result.data val fileUrl = data?.data @@ -241,7 +248,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> logcat { "onActivityResult for Google Password Manager import flow. resultCode=${result.resultCode}" } - if (result.resultCode == Activity.RESULT_OK) { + if (result.resultCode == RESULT_OK) { result.data?.let { when (IntentCompat.getParcelableExtra(it, RESULT_KEY_DETAILS, ImportGooglePasswordResult::class.java)) { is Success -> observePasswordInputUpdates() @@ -255,28 +262,49 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { private val importGoogleBookmarksFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - logcat { "onActivityResult for Google Takeout bookmark import flow. resultCode=${result.resultCode}" } + logcat { "Bookmark-import onActivityResult for Google Bookmark import flow. resultCode=${result.resultCode}" } 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 -> {} - } + val bookmarkResult = + IntentCompat.getParcelableExtra( + intent, + ImportGoogleBookmarkResult.RESULT_KEY_DETAILS, + ImportGoogleBookmarkResult::class.java, + ) + handleBookmarkImportResult(bookmarkResult) } } } + private fun handleBookmarkImportResult(result: ImportGoogleBookmarkResult?) { + when (result) { + is ImportGoogleBookmarkResult.Success -> { + "Successfully imported ${result.importedCount} bookmarks".showSnackbar() + hidePreImportDialog() + } + is ImportGoogleBookmarkResult.Error -> { + val errorMessage = + when (result.reason) { + DownloadError -> "Failed to download bookmark data" + ErrorParsingBookmarks -> "Failed to parse bookmark data" + Unknown -> "Failed to import bookmarks" + WebViewError -> "WebView error occurred" + } + errorMessage.showSnackbar() + hidePreImportDialog() + } + is ImportGoogleBookmarkResult.UserCancelled -> logcat { "Bookmark-import cancelled at stage: ${result.stage}" } + null -> logcat { "Bookmark-import result is null" } + } + } + + private fun hidePreImportDialog() { + supportFragmentManager.findFragmentByTag(TAG_PRE_IMPORT_BOOKMARKS)?.let { fragment -> + (fragment as? ImportFromGoogleBookmarksPreImportDialog)?.dismiss() + } + } + private fun observePasswordInputUpdates() { passwordImportWatcher += lifecycleScope.launch { @@ -383,12 +411,19 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { startActivity(browserNav.openInNewTab(this@AutofillInternalSettingsActivity, url)) } } - binding.importBookmarksLaunchGoogleTakeoutCustomFlow.setClickListener { + binding.importBookmarksLaunchGoogleTakeoutCustomFlowWithPreImportDialog.setClickListener { lifecycleScope.launch { if (importGooglePasswordsCapabilityChecker.webViewCapableOfImporting()) { try { - val intent = globalActivityStarter.startIntent(this@AutofillInternalSettingsActivity, AutofillImportViaGoogleTakeoutScreen) - importGoogleBookmarksFlowLauncher.launch(intent) + val dialog = ImportFromGoogleBookmarksPreImportDialog.instance(AutofillDevSettings) + dialog.setImportClickedCallback { + val intent = globalActivityStarter.startIntent( + this@AutofillInternalSettingsActivity, + AutofillImportViaGoogleTakeoutScreen(AutofillDevSettings), + ) + importGoogleBookmarksFlowLauncher.launch(intent) + } + dialog.show(supportFragmentManager, TAG_PRE_IMPORT_BOOKMARKS) } catch (e: Exception) { val message = "Error launching bookmark import flow: ${e.message}" logcat { message } @@ -399,13 +434,41 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } } - - binding.importBookmarksImportTakeoutZip.setClickListener { + binding.importBookmarksShowSuccessScreenWithSimulatedData.setClickListener { val intent = - Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" + globalActivityStarter.startIntent( + this@AutofillInternalSettingsActivity, + AutofillImportViaGoogleTakeoutScreenResultSuccess(AutofillDevSettings, bookmarkCount = 125), + ) + importGoogleBookmarksFlowLauncher.launch(intent) + } + binding.importBookmarksShowFailureScreenWithSimulatedData.setClickListener { + val intent = + globalActivityStarter.startIntent( + this@AutofillInternalSettingsActivity, + AutofillImportViaGoogleTakeoutScreenResultError(AutofillDevSettings, DownloadError), + ) + importGoogleBookmarksFlowLauncher.launch(intent) + } + binding.importBookmarksLaunchGoogleTakeoutCustomFlow.setClickListener { + lifecycleScope.launch { + if (importGooglePasswordsCapabilityChecker.webViewCapableOfImporting()) { + val intent = globalActivityStarter.startIntent( + this@AutofillInternalSettingsActivity, + AutofillImportViaGoogleTakeoutScreen(AutofillDevSettings), + ) + importGoogleBookmarksFlowLauncher.launch(intent) + } 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) } @@ -664,7 +727,6 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { private fun onUserChoseToClearSavedLogins() { lifecycleScope.launch(dispatchers.io()) { - autofillStore.getCredentialCount() val deleted = autofillStore.deleteAllCredentials().size withContext(dispatchers.main()) { Toast.makeText(this@AutofillInternalSettingsActivity, "Deleted %d logins".format(deleted), Toast.LENGTH_SHORT).show() @@ -808,5 +870,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { "spreadprivacy.com", "duck.com", ) + + private const val TAG_PRE_IMPORT_BOOKMARKS = "ImportFromGoogleBookmarksPreImportDialog" } } 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 23e3fdc2448c..bfeb18a1217d 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 @@ -166,6 +166,24 @@ android:layout_height="wrap_content" app:primaryText="@string/autofillDevSettingsImportBookmarksGoogleTakeoutImportFlowTitle" /> + + + + + + Import Bookmarks Launch Google Takeout (normal tab) Launch Bookmarks import flow + Launch Bookmarks import flow (with preimport dialog) Choose zip file (downloaded from Takeout) View Bookmarks + Show bookmark import failure screen (simulated data) + Show bookmark import success screen (simulated data) Clear Previous Google Imports interactions Tap to forget whether we\'ve previously imported, what we chose when previously prompted etc…