diff --git a/Task b/Task new file mode 100644 index 000000000000..e69de29bb2d1 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 84de7dc67da1..ed6d917dea70 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 @@ -16,7 +16,9 @@ package com.duckduckgo.autofill.api +import android.os.Bundle import android.os.Parcelable +import androidx.core.os.BundleCompat import androidx.fragment.app.DialogFragment import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType @@ -184,6 +186,50 @@ interface EmailProtectionInContextSignUpDialog { } } +/** + * Dialog which prompts the user to import bookmarks from Google + * Results should be handled by defining a fragmentResultListener with key [ImportBookmarksPreImportDialog.RESULT_KEY] + * e.g., + * supportFragmentManager.setFragmentResultListener(ImportBookmarksPreImportDialog.RESULT_KEY, this) { _, result -> + * val choice = ImportBookmarksPreImportDialog.extractResult(result) + * } + */ +interface ImportBookmarksPreImportDialog { + + /** + * Result of the dialog, as determined by which button the user pressed or if they cancelled the dialog + */ + @Parcelize + sealed interface ImportBookmarksPreImportResult : Parcelable { + + /** + * User chose to proceed with the Google import + */ + @Parcelize + data object ImportBookmarks : ImportBookmarksPreImportResult + + /** + * User chose to select a bookmarks file + */ + @Parcelize + data object SelectBookmarksFile : ImportBookmarksPreImportResult + + /** + * User cancelled the dialog + */ + @Parcelize + data object Cancel : ImportBookmarksPreImportResult + } + + companion object { + const val RESULT_KEY = "ImportBookmarksPreImportDialog" + + fun extractResult(bundle: Bundle): ImportBookmarksPreImportResult? { + return BundleCompat.getParcelable(bundle, "result", ImportBookmarksPreImportResult::class.java) + } + } +} + /** * Factory used to get instances of the various autofill dialogs */ @@ -258,6 +304,11 @@ interface CredentialAutofillDialogFactory { tabId: String, url: String, ): DialogFragment + + /** + * Creates a dialog which prompts the user to import bookmarks from Google + */ + fun autofillImportBookmarksPreImportDialog(importSource: AutofillImportBookmarksLaunchSource): DialogFragment } private fun prefix( @@ -280,4 +331,5 @@ enum class AutofillImportLaunchSource(val value: String) : Parcelable { enum class AutofillImportBookmarksLaunchSource(val value: String) : Parcelable { Unknown("unknown"), AutofillDevSettings("autofill_dev_settings"), + BookmarksScreen("bookmarks_screen"), } diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillScreens.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillScreens.kt index 37e6470b8c34..2c83993011c5 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillScreens.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillScreens.kt @@ -54,6 +54,12 @@ sealed interface AutofillScreens { val loginCredentials: LoginCredentials, val source: AutofillScreenLaunchSource, ) : ActivityParams + + /** + * Launch the Google Bookmarks import flow + * @param importSource is used to indicate from where in the app the import was launched + */ + data class AutofillImportViaGoogleTakeoutScreen(val importSource: AutofillImportBookmarksLaunchSource) : ActivityParams } enum class AutofillScreenLaunchSource { 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 bb9aa8118aff..d7761a0626d8 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 @@ -29,9 +29,9 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.isInternalBuild import com.duckduckgo.autofill.api.AutofillImportBookmarksLaunchSource +import com.duckduckgo.autofill.api.AutofillScreens 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 @@ -46,7 +46,7 @@ import logcat.logcat import javax.inject.Inject @InjectWith(ActivityScope::class) -@ContributeToActivityStarter(AutofillImportViaGoogleTakeoutScreen::class) +@ContributeToActivityStarter(AutofillScreens.AutofillImportViaGoogleTakeoutScreen::class) @ContributeToActivityStarter(AutofillImportViaGoogleTakeoutScreenResultSuccess::class) @ContributeToActivityStarter(AutofillImportViaGoogleTakeoutScreenResultError::class) class ImportGoogleBookmarksWebFlowActivity : @@ -58,13 +58,13 @@ class ImportGoogleBookmarksWebFlowActivity : val binding: ActivityImportGoogleBookmarksWebflowBinding by viewBinding() private var isOverlayCurrentlyShown = false + private var isOnResultScreen = false private val canShowDebugMenu: Boolean get() = appBuildConfig.isInternalBuild() && isOverlayCurrentlyShown private val launchSource: AutofillImportBookmarksLaunchSource by lazy { - intent.getActivityParams(ImportViaGoogleTakeoutScreen::class.java)?.launchSource - ?: AutofillImportBookmarksLaunchSource.Unknown + intent.getActivityParams(ImportViaGoogleTakeoutScreen::class.java)?.launchSource ?: AutofillImportBookmarksLaunchSource.Unknown } override fun onCreate(savedInstanceState: Bundle?) { @@ -78,15 +78,23 @@ class ImportGoogleBookmarksWebFlowActivity : val errorResult = intent.getActivityParams(AutofillImportViaGoogleTakeoutScreenResultError::class.java) when { - successResult != null -> showSuccessFragment(successResult.bookmarkCount) - errorResult != null -> showErrorFragment(errorResult.errorReason) + successResult != null -> { + isOnResultScreen = true + showSuccessFragment(successResult.bookmarkCount) + } + errorResult != null -> { + isOnResultScreen = true + showErrorFragment(errorResult.errorReason) + } else -> launchWebFlow() } } private fun launchWebFlow() { logcat { "Bookmark-import: Starting webflow" } + isOnResultScreen = false replaceFragment(ImportGoogleBookmarksWebFlowFragment()) + updateToolbarTitle() invalidateOptionsMenu() } @@ -110,6 +118,8 @@ class ImportGoogleBookmarksWebFlowActivity : replaceFragment(successFragment) isOverlayCurrentlyShown = false + isOnResultScreen = true + updateToolbarTitle() invalidateOptionsMenu() } @@ -125,6 +135,8 @@ class ImportGoogleBookmarksWebFlowActivity : replaceFragment(errorFragment) isOverlayCurrentlyShown = false + isOnResultScreen = true + updateToolbarTitle() invalidateOptionsMenu() } @@ -147,6 +159,7 @@ class ImportGoogleBookmarksWebFlowActivity : private fun showProgressOverlay() { isOverlayCurrentlyShown = true + updateToolbarTitle() val progressFragment = supportFragmentManager.findFragmentByTag(PROGRESS_OVERLAY_TAG) if (progressFragment == null) { @@ -159,6 +172,7 @@ class ImportGoogleBookmarksWebFlowActivity : private fun hideProgressOverlay() { isOverlayCurrentlyShown = false + updateToolbarTitle() val progressFragment = supportFragmentManager.findFragmentByTag(PROGRESS_OVERLAY_TAG) if (progressFragment != null) { @@ -249,6 +263,7 @@ class ImportGoogleBookmarksWebFlowActivity : toggleOverlay() true } + else -> super.onOptionsItemSelected(item) } } @@ -268,7 +283,16 @@ class ImportGoogleBookmarksWebFlowActivity : setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_close_24) } supportActionBar?.setDisplayHomeAsUpEnabled(true) - setTitle("") + updateToolbarTitle() + } + + private fun updateToolbarTitle() { + val title = if (isOverlayCurrentlyShown || isOnResultScreen) { + "" + } else { + getString(R.string.importBookmarksFromGoogleWebFlowTitle) + } + setTitle(title) } companion object { @@ -283,11 +307,6 @@ object ImportGoogleBookmark { ) : ActivityParams, Parcelable - @Parcelize - data class AutofillImportViaGoogleTakeoutScreen( - private val source: AutofillImportBookmarksLaunchSource, - ) : ImportViaGoogleTakeoutScreen(source) - @Parcelize data class AutofillImportViaGoogleTakeoutScreenResultSuccess( private val source: AutofillImportBookmarksLaunchSource, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/CredentialAutofillDialogAndroidFactory.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/CredentialAutofillDialogAndroidFactory.kt index 099f144be784..aeeba2ea092f 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/CredentialAutofillDialogAndroidFactory.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/CredentialAutofillDialogAndroidFactory.kt @@ -17,6 +17,7 @@ package com.duckduckgo.autofill.impl.ui import androidx.fragment.app.DialogFragment +import com.duckduckgo.autofill.api.AutofillImportBookmarksLaunchSource import com.duckduckgo.autofill.api.AutofillImportLaunchSource import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType @@ -24,6 +25,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.impl.email.EmailProtectionChooseEmailFragment import com.duckduckgo.autofill.impl.email.incontext.prompt.EmailProtectionInContextSignUpPromptFragment +import com.duckduckgo.autofill.impl.ui.credential.management.importbookmark.google.preimport.ImportFromGoogleBookmarksPreImportDialog import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialog import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.AutofillUseGeneratedPasswordDialogFragment import com.duckduckgo.autofill.impl.ui.credential.saving.AutofillSavingCredentialsDialogFragment @@ -107,4 +109,8 @@ class CredentialAutofillDialogAndroidFactory @Inject constructor() : CredentialA override fun autofillImportPasswordsPromoDialog(importSource: AutofillImportLaunchSource, tabId: String, url: String): DialogFragment { return ImportFromGooglePasswordsDialog.instance(importSource = importSource, tabId = tabId, originalUrl = url) } + + override fun autofillImportBookmarksPreImportDialog(importSource: AutofillImportBookmarksLaunchSource): DialogFragment { + return ImportFromGoogleBookmarksPreImportDialog.instance(importSource = importSource) + } } 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 index 7f0aacef86b6..9210b1558d5e 100644 --- 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 @@ -23,13 +23,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.BundleCompat +import androidx.fragment.app.setFragmentResult import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.autofill.api.AutofillImportBookmarksLaunchSource import com.duckduckgo.autofill.api.AutofillImportBookmarksLaunchSource.Unknown +import com.duckduckgo.autofill.api.ImportBookmarksPreImportDialog +import com.duckduckgo.autofill.api.ImportBookmarksPreImportDialog.ImportBookmarksPreImportResult 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 @@ -57,12 +59,6 @@ class ImportFromGoogleBookmarksPreImportDialog : BottomSheetDialogFragment() { @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 @@ -104,10 +100,31 @@ class ImportFromGoogleBookmarksPreImportDialog : BottomSheetDialogFragment() { with(binding.importButton) { setOnClickListener { onImportButtonClicked() } } + + with(binding.selectFileButton) { + setOnClickListener { onSelectFileButtonClicked() } + } } private fun onImportButtonClicked() { - importClickedCallback?.invoke() + ignoreCancellationEvents = true + + // Send fragment result + val result = Bundle().apply { + putParcelable(KEY_RESULT, ImportBookmarksPreImportResult.ImportBookmarks) + putParcelable(KEY_IMPORT_SOURCE, getLaunchSource()) + } + setFragmentResult(ImportBookmarksPreImportDialog.RESULT_KEY, result) + } + + private fun onSelectFileButtonClicked() { + ignoreCancellationEvents = true + + val result = Bundle().apply { + putParcelable(KEY_RESULT, ImportBookmarksPreImportResult.SelectBookmarksFile) + putParcelable(KEY_IMPORT_SOURCE, getLaunchSource()) + } + setFragmentResult(ImportBookmarksPreImportDialog.RESULT_KEY, result) } override fun onCancel(dialog: DialogInterface) { @@ -115,32 +132,42 @@ class ImportFromGoogleBookmarksPreImportDialog : BottomSheetDialogFragment() { logcat(VERBOSE) { "onCancel: Ignoring cancellation event" } return } + + val result = Bundle().apply { + putParcelable(KEY_RESULT, ImportBookmarksPreImportResult.Cancel) + putParcelable(KEY_IMPORT_SOURCE, getLaunchSource()) + } + setFragmentResult(ImportBookmarksPreImportDialog.RESULT_KEY, result) } private fun configureCloseButton(binding: ContentImportBookmarksFromGooglePreimportDialogBinding) { - binding.closeButton.setOnClickListener { (dialog as BottomSheetDialog).animateClosed() } + binding.closeButton.setOnClickListener { + ignoreCancellationEvents = true + + val result = Bundle().apply { + putParcelable(KEY_RESULT, ImportBookmarksPreImportResult.Cancel) + putParcelable(KEY_IMPORT_SOURCE, getLaunchSource()) + } + setFragmentResult(ImportBookmarksPreImportDialog.RESULT_KEY, result) + + (dialog as BottomSheetDialog).animateClosed() + } } companion object { private const val KEY_LAUNCH_SOURCE = "launchSource" + private const val KEY_IMPORT_SOURCE = "importSource" + private const val KEY_RESULT = "result" 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/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 index eafe18fe2283..9e2af7ce57f7 100644 --- 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 @@ -79,14 +79,25 @@ + + + android:text="@string/importBookmarksFromGooglePreImportDialogSelectFileButton" + app:daxButtonSize="large" + app:layout_constraintEnd_toEndOf="@id/guidelineEnd" + app:layout_constraintStart_toStartOf="@id/guidelineStart" + app:layout_constraintTop_toBottomOf="@id/importButton" /> diff --git a/autofill/autofill-impl/src/main/res/values/donottranslate.xml b/autofill/autofill-impl/src/main/res/values/donottranslate.xml index 43358d1b225a..dbc5b1e98b1d 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -23,7 +23,8 @@ Import Your Bookmarks Import Bookmarks From Google Google may ask you to sign in or enter your password to confirm. - Import Now + Import From Google + Select Bookmarks File… @string/importToDuckDuckGo Your bookmarks are being imported. This may take a few moments. 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 bd094514f72d..199b12928774 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 @@ -19,6 +19,7 @@ package com.duckduckgo.autofill.internal import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import android.webkit.CookieManager import android.widget.Toast @@ -33,7 +34,10 @@ 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.AutofillImportViaGoogleTakeoutScreen import com.duckduckgo.autofill.api.AutofillScreens.AutofillPasswordsManagementScreen +import com.duckduckgo.autofill.api.ImportBookmarksPreImportDialog +import com.duckduckgo.autofill.api.ImportBookmarksPreImportDialog.ImportBookmarksPreImportResult import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.configuration.AutofillJavascriptEnvironmentConfiguration @@ -54,7 +58,6 @@ 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.ImportGoogleBookmark.AutofillImportViaGoogleTakeoutScreenResultError import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmark.AutofillImportViaGoogleTakeoutScreenResultSuccess import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult @@ -225,6 +228,19 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + private val importBookmarksFileLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val selectedFile = result.data?.data + if (selectedFile != null) { + // Dismiss dialog since user successfully selected a file + hidePreImportDialog() + importBookmarksFromFile(selectedFile) + } + } + // Note: If resultCode != RESULT_OK, user cancelled file picker - dialog stays open + } + private suspend fun onBookmarksExtracted(extractionResult: ExtractionResult.Success) { when ( val importResult = @@ -395,6 +411,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { configureDeclineCounterHandlers() configureImportPasswordsEventHandlers() configureImportBookmarksEventHandlers() + setupImportBookmarksResultListener() } private fun configureReportBreakagesHandlers() { @@ -418,13 +435,6 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { if (importGooglePasswordsCapabilityChecker.webViewCapableOfImporting()) { try { 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}" @@ -860,6 +870,64 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { Toast.makeText(this, R.string.autofillDevSettingsGoogleLogoutSuccess, Toast.LENGTH_SHORT).show() } + private fun launchBookmarkImportChooseFile() { + val intent = Intent() + .setType("text/html") + .setAction(Intent.ACTION_GET_CONTENT) + + importBookmarksFileLauncher.launch( + Intent.createChooser(intent, "Select Bookmarks File"), + ) + } + + private fun setupImportBookmarksResultListener() { + supportFragmentManager.setFragmentResultListener(ImportBookmarksPreImportDialog.RESULT_KEY, this) { _, bundle -> + val result = ImportBookmarksPreImportDialog.extractResult(bundle) + + when (result) { + ImportBookmarksPreImportResult.ImportBookmarks -> { + val intent = globalActivityStarter.startIntent( + this@AutofillInternalSettingsActivity, + AutofillImportViaGoogleTakeoutScreen(AutofillDevSettings), + ) + importGoogleBookmarksFlowLauncher.launch(intent) + } + ImportBookmarksPreImportResult.SelectBookmarksFile -> launchBookmarkImportChooseFile() + ImportBookmarksPreImportResult.Cancel, null -> hidePreImportDialog() + } + } + } + + private fun importBookmarksFromFile(uri: Uri) { + lifecycleScope.launch(dispatchers.io()) { + try { + val result = takeoutBookmarkImporter.importBookmarks( + uri, + ImportFolder.Folder("Imported Bookmarks"), + ) + + withContext(dispatchers.main()) { + when (result) { + is ImportSavedSitesResult.Success -> { + val message = "Successfully imported ${result.savedSites.size} bookmarks" + Toast.makeText(this@AutofillInternalSettingsActivity, message, Toast.LENGTH_LONG).show() + } + is ImportSavedSitesResult.Error -> { + val message = "Failed to import bookmarks: ${result.exception.message}" + Toast.makeText(this@AutofillInternalSettingsActivity, message, Toast.LENGTH_LONG).show() + } + } + } + } catch (e: Exception) { + withContext(dispatchers.main()) { + val message = "Error importing bookmarks: ${e.message}" + Toast.makeText(this@AutofillInternalSettingsActivity, message, Toast.LENGTH_LONG).show() + logcat { "Error importing bookmarks from file: ${e.message}" } + } + } + } + } + companion object { fun intent(context: Context): Intent = Intent(context, AutofillInternalSettingsActivity::class.java) diff --git a/saved-sites/saved-sites-impl/build.gradle b/saved-sites/saved-sites-impl/build.gradle index fc8cb1d4d8da..2754f3d44ada 100644 --- a/saved-sites/saved-sites-impl/build.gradle +++ b/saved-sites/saved-sites-impl/build.gradle @@ -37,6 +37,7 @@ dependencies { implementation project(path: ':navigation-api') implementation project(path: ':new-tab-page-api') implementation project(path: ':data-store-api') + implementation project(path: ':autofill-api') implementation project(path: ':saved-sites-store') anvil project(path: ':anvil-compiler') diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksActivity.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksActivity.kt index 4191a8c2296e..a9ac055d6c27 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksActivity.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksActivity.kt @@ -31,6 +31,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.core.view.children import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.SimpleItemAnimator @@ -38,6 +39,10 @@ import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.tabs.BrowserNav +import com.duckduckgo.autofill.api.AutofillImportBookmarksLaunchSource +import com.duckduckgo.autofill.api.AutofillScreens.AutofillImportViaGoogleTakeoutScreen +import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory +import com.duckduckgo.autofill.api.ImportBookmarksPreImportDialog import com.duckduckgo.browser.api.ui.BrowserScreens.BookmarksScreenNoParams import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.menu.PopupMenu @@ -72,11 +77,13 @@ import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.Expor import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.ImportedSavedSites import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchAddFolder import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchBookmarkExport -import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchBookmarkImport +import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchBookmarkImportFile +import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchBookmarkImportTakeoutFlow import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchSyncSettings import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.OpenBookmarkFolder import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.OpenSavedSite import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.ReevalutePromotions +import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.ShowBookmarkImportDialog import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.ShowBrowserMenu import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.ShowEditBookmarkFolder import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.ShowEditSavedSite @@ -99,6 +106,7 @@ import java.util.Date import java.util.Locale import java.util.TimeZone import javax.inject.Inject +import com.duckduckgo.autofill.api.ImportBookmarksPreImportDialog.ImportBookmarksPreImportResult as ImportResult import com.duckduckgo.mobile.android.R as commonR @InjectWith(ActivityScope::class) @@ -123,6 +131,9 @@ class BookmarksActivity : DuckDuckGoActivity(), BookmarksScreenPromotionPlugin.C @Inject lateinit var bookmarksSortingFeature: BookmarksSortingFeature + @Inject + lateinit var credentialAutofillDialogFactory: CredentialAutofillDialogFactory + private lateinit var bookmarksAdapter: BookmarksAdapter private lateinit var searchListener: BookmarksQueryListener @@ -167,6 +178,11 @@ class BookmarksActivity : DuckDuckGoActivity(), BookmarksScreenPromotionPlugin.C viewModel.userReturnedFromSyncSettings() } + private val importGoogleBookmarksFlowLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + handleBookmarkImportResult(result.resultCode) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) contentBookmarksBinding = ContentBookmarksBinding.bind(binding.root) @@ -177,6 +193,7 @@ class BookmarksActivity : DuckDuckGoActivity(), BookmarksScreenPromotionPlugin.C observeViewModel() observeItemsToDisplay() initializeSearchBar() + setupImportBookmarksResultListener() viewModel.fetchBookmarksAndFolders(getParentFolderId()) } @@ -266,9 +283,12 @@ class BookmarksActivity : DuckDuckGoActivity(), BookmarksScreenPromotionPlugin.C if (resultCode == RESULT_OK) { val selectedFile = data?.data if (selectedFile != null) { + // Dismiss dialog since user successfully selected a file + dismissImportBookmarksDialog() viewModel.importBookmarks(selectedFile) } } + // Note: If resultCode != RESULT_OK, user cancelled file picker - dialog stays open } EXPORT_BOOKMARKS_REQUEST_CODE -> { @@ -389,13 +409,55 @@ class BookmarksActivity : DuckDuckGoActivity(), BookmarksScreenPromotionPlugin.C is LaunchSyncSettings -> launchSyncSettings() is ReevalutePromotions -> configurePromotionsContainer() is ShowBrowserMenu -> showBookmarksPopupMenu(it.buttonsDisabled, it.sortingMode) - is LaunchBookmarkImport -> launchBookmarkImport() + is LaunchBookmarkImportFile -> launchBookmarkImportChooseFile() + is LaunchBookmarkImportTakeoutFlow -> launchBookmarkImportTakeoutFlow() + is ShowBookmarkImportDialog -> showBookmarkImportDialog() is LaunchBookmarkExport -> launchBookmarkExport() is LaunchAddFolder -> launchAddFolder() } } } + private fun setupImportBookmarksResultListener() { + supportFragmentManager.setFragmentResultListener(ImportBookmarksPreImportDialog.RESULT_KEY, this) { _, bundle -> + val result = ImportBookmarksPreImportDialog.extractResult(bundle) + + when (result) { + ImportResult.ImportBookmarks -> launchBookmarkImportTakeoutFlow() + ImportResult.SelectBookmarksFile -> launchBookmarkImportChooseFile() + ImportResult.Cancel, null -> { + // User explicitly cancelled - dismiss dialog + dismissImportBookmarksDialog() + } + } + } + } + + private fun showBookmarkImportDialog() { + val dialog = credentialAutofillDialogFactory.autofillImportBookmarksPreImportDialog( + importSource = AutofillImportBookmarksLaunchSource.BookmarksScreen, + ) + dialog.show(supportFragmentManager, DIALOG_TAG_IMPORT_BOOKMARKS) + } + + private fun handleBookmarkImportResult(resultCode: Int) { + when (resultCode) { + RESULT_OK -> dismissImportBookmarksDialog() + else -> { + // Flow was cancelled or failed - keep dialog open so user can try again + } + } + } + + private fun dismissImportBookmarksDialog() { + val dialog = supportFragmentManager.findFragmentByTag(DIALOG_TAG_IMPORT_BOOKMARKS) + dialog?.let { + if (it is DialogFragment) { + it.dismiss() + } + } + } + private fun observeItemsToDisplay() { viewModel.itemsToDisplay.onEach { items -> bookmarksAdapter.setItems( @@ -425,7 +487,14 @@ class BookmarksActivity : DuckDuckGoActivity(), BookmarksScreenPromotionPlugin.C faviconPrompt.show() } - private fun launchBookmarkImport() { + private fun launchBookmarkImportTakeoutFlow() { + val intent = globalActivityStarter.startIntent( + this, + AutofillImportViaGoogleTakeoutScreen(AutofillImportBookmarksLaunchSource.BookmarksScreen), + ) + importGoogleBookmarksFlowLauncher.launch(intent) + } + private fun launchBookmarkImportChooseFile() { val intent = Intent() .setType("text/html") .setAction(Intent.ACTION_GET_CONTENT) @@ -793,6 +862,10 @@ class BookmarksActivity : DuckDuckGoActivity(), BookmarksScreenPromotionPlugin.C } } + private fun String.showSnackbar(duration: Int = Snackbar.LENGTH_LONG) { + Snackbar.make(binding.root, this, duration).show() + } + companion object { const val SAVED_SITE_URL_EXTRA = "SAVED_SITE_URL_EXTRA" @@ -828,5 +901,7 @@ class BookmarksActivity : DuckDuckGoActivity(), BookmarksScreenPromotionPlugin.C private val formatter: SimpleDateFormat = SimpleDateFormat("yyyyMMdd", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") } + + private const val DIALOG_TAG_IMPORT_BOOKMARKS = "ImportBookmarksPreImportDialog" } } diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksViewModel.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksViewModel.kt index 151f3c99d7b5..32291c6edee6 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksViewModel.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksViewModel.kt @@ -23,6 +23,7 @@ import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.SingleLiveEvent import com.duckduckgo.di.scopes.ActivityScope @@ -47,7 +48,7 @@ import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.Expor import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.ImportedSavedSites import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchAddFolder import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchBookmarkExport -import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchBookmarkImport +import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.LaunchBookmarkImportFile import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.OpenBookmarkFolder import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.OpenSavedSite import com.duckduckgo.savedsites.impl.bookmarks.BookmarksViewModel.Command.ShowBrowserMenu @@ -84,6 +85,7 @@ class BookmarksViewModel @Inject constructor( private val faviconsFetchingPrompt: FaviconsFetchingPrompt, private val bookmarksDataStore: BookmarksDataStore, private val dispatcherProvider: DispatcherProvider, + private val autofillFeature: AutofillFeature, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : EditSavedSiteListener, AddBookmarkFolderListener, EditBookmarkFolderListener, DeleteBookmarkListener, ViewModel() { @@ -106,7 +108,9 @@ class BookmarksViewModel @Inject constructor( class ConfirmDeleteBookmarkFolder(val bookmarkFolder: BookmarkFolder) : Command() data class ImportedSavedSites(val importSavedSitesResult: ImportSavedSitesResult) : Command() data class ExportedSavedSites(val exportSavedSitesResult: ExportSavedSitesResult) : Command() - data object LaunchBookmarkImport : Command() + data object ShowBookmarkImportDialog : Command() + data object LaunchBookmarkImportFile : Command() + data object LaunchBookmarkImportTakeoutFlow : Command() data object LaunchBookmarkExport : Command() data object LaunchAddFolder : Command() data object ShowFaviconsPrompt : Command() @@ -516,7 +520,14 @@ class BookmarksViewModel @Inject constructor( fun onImportBookmarksClicked() { pixel.fire(SavedSitesPixelName.BOOKMARK_MENU_IMPORT_CLICKED) - command.value = LaunchBookmarkImport + + viewModelScope.launch { + command.value = if (autofillFeature.canImportBookmarksFromGoogleTakeout().isEnabled()) { + Command.ShowBookmarkImportDialog + } else { + LaunchBookmarkImportFile + } + } } fun onExportBookmarksClicked() { diff --git a/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksViewModelTest.kt b/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksViewModelTest.kt index 7868e3f15d83..5890ef6f1610 100644 --- a/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksViewModelTest.kt +++ b/saved-sites/saved-sites-impl/src/test/java/com/duckduckgo/savedsites/impl/bookmarks/BookmarksViewModelTest.kt @@ -16,12 +16,16 @@ package com.duckduckgo.savedsites.impl.bookmarks +import android.annotation.SuppressLint import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Observer import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.savedsites.api.SavedSitesRepository import com.duckduckgo.savedsites.api.models.BookmarkFolder import com.duckduckgo.savedsites.api.models.BookmarkFolderItem @@ -50,6 +54,7 @@ import org.junit.Test import org.mockito.ArgumentMatchers.anyString import org.mockito.kotlin.* +@SuppressLint("DenyListedApi") class BookmarksViewModelTest { @get:Rule @@ -64,6 +69,8 @@ class BookmarksViewModelTest { @Suppress("unused") val coroutineRule = CoroutineTestRule() + private val autofillFeature = FakeFeatureToggleFactory.create(AutofillFeature::class.java) + private val commandCaptor = argumentCaptor() private val viewStateCaptor = argumentCaptor() @@ -109,6 +116,7 @@ class BookmarksViewModelTest { faviconsFetchingPrompt, bookmarksDataStore, coroutineRule.testDispatcherProvider, + autofillFeature, coroutineRule.testScope, ) model.viewState.observeForever(viewStateObserver) @@ -124,6 +132,8 @@ class BookmarksViewModelTest { whenever(bookmarksDataStore.getSortingMode()).thenReturn(NAME) testee.fetchBookmarksAndFolders(SavedSitesNames.BOOKMARKS_ROOT) + + autofillFeature.canImportBookmarksFromGoogleTakeout().setRawStoredState(State(true)) } @After @@ -591,12 +601,28 @@ class BookmarksViewModelTest { } @Test - fun whenImportBookmarksClickedThenPixelAndCommandSent() { + fun whenImportBookmarksClickedThenPixelSent() { testee.onImportBookmarksClicked() verify(pixel).fire(SavedSitesPixelName.BOOKMARK_MENU_IMPORT_CLICKED) + } + + @Test + fun whenImportBookmarksClickedAndFeatureEnabledThenShowDialog() { + autofillFeature.canImportBookmarksFromGoogleTakeout().setRawStoredState(State(true)) + testee.onImportBookmarksClicked() + + verify(commandObserver).onChanged(commandCaptor.capture()) + assertEquals(BookmarksViewModel.Command.ShowBookmarkImportDialog, commandCaptor.lastValue) + } + + @Test + fun whenImportBookmarksClickedAndFeatureDisabledThenLaunchFileImport() { + autofillFeature.canImportBookmarksFromGoogleTakeout().setRawStoredState(State(false)) + testee.onImportBookmarksClicked() + verify(commandObserver).onChanged(commandCaptor.capture()) - assertEquals(BookmarksViewModel.Command.LaunchBookmarkImport, commandCaptor.lastValue) + assertEquals(BookmarksViewModel.Command.LaunchBookmarkImportFile, commandCaptor.lastValue) } @Test