From 1a6c073476dc7372b8406d44d1d60a741d60df91 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Thu, 21 Nov 2024 14:42:42 +0000 Subject: [PATCH] Launch import flow from Password management screen (#5098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/608920331025315/1208434183978073/f ### Description Adds UI which allows users to access the flow for importing passwords directly from Google Password Manager. ![output](https://github.com/user-attachments/assets/0f8c6eb4-daea-4d93-96a5-400b63f736ef) ### Steps to test this PR **Empty state** - [ ] Fresh install - [ ] Visit `Passwords` screen and verify you see `Import Passwords From Google` button in the empty state (if `WebView` version supports it) **Pre-import dialog** - [ ] Tap the button, and verify the bottom sheet looks good **Success results dialog** - [ ] Tap `Open Google Passwords` button and complete the flow - [ ] Verify that you see the success dialog confirming how many passwords were imported. **Success results dialog (with duplicates detected)** - [ ] [If you didn't see any duplicates in the last step] repeat previous step and verify duplicates are mentioned but not imported **Failure dialog** It's possible the flow couldn't be completed if the user enabled an old setting in GPM to encrypt passwords with a passphrase. Expecting this to be uncommon, but here's how to test the UI if this happens: - [ ] Apply [Patch for testing: encrypted with passphrase](https://app.asana.com/0/1208775021175583/1208775021175583/f) - [ ] Start the import flow, and you'll see the error page - [ ] Tap the ✖️ button - [ ] Verify the results dialog shows an error state --- autofill/autofill-impl/build.gradle | 1 + .../AutofillAuthorizationGracePeriod.kt | 38 ++- .../gpm/webflow/ImportGooglePasswordResult.kt | 3 +- .../ImportGooglePasswordsWebFlowFragment.kt | 15 +- .../ImportGooglePasswordsWebFlowViewModel.kt | 18 +- .../autofill/impl/pixel/AutofillPixelNames.kt | 12 +- .../management/AutofillSettingsViewModel.kt | 41 ++- .../google/ImportFromGooglePasswordsDialog.kt | 270 ++++++++++++++++++ ...mportFromGooglePasswordsDialogViewModel.kt | 81 ++++++ .../viewing/AutofillManagementListMode.kt | 89 ++++-- .../autofill_gpm_export_instruction.xml | 42 +++ ...ofill_rounded_border_import_background.xml | 23 ++ .../res/drawable/ic_check_recolorable_24.xml | 14 + .../drawable/ic_cross_recolorable_red_24.xml | 13 + .../res/drawable/ic_passwords_import_128.xml | 57 ++++ .../src/main/res/drawable/ic_success_128.xml | 124 ++++++++ ...management_credential_list_empty_state.xml | 22 +- ...ent_import_from_google_password_dialog.xml | 56 ++++ ...ntent_import_google_password_post_flow.xml | 79 +++++ ..._google_password_post_flow_in_progress.xml | 80 ++++++ ...mport_google_password_post_flow_result.xml | 85 ++++++ ...ontent_import_google_password_pre_flow.xml | 84 ++++++ .../main/res/menu/autofill_list_mode_menu.xml | 12 +- .../src/main/res/values/donottranslate.xml | 21 ++ ...llTimeBasedAuthorizationGracePeriodTest.kt | 33 +++ .../AutofillSettingsViewModelTest.kt | 55 ++++ ...tFromGooglePasswordsDialogViewModelTest.kt | 141 +++++++++ .../AutofillInternalSettingsActivity.kt | 12 +- 28 files changed, 1468 insertions(+), 53 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialog.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModel.kt create mode 100644 autofill/autofill-impl/src/main/res/drawable/autofill_gpm_export_instruction.xml create mode 100644 autofill/autofill-impl/src/main/res/drawable/autofill_rounded_border_import_background.xml create mode 100644 autofill/autofill-impl/src/main/res/drawable/ic_check_recolorable_24.xml create mode 100644 autofill/autofill-impl/src/main/res/drawable/ic_cross_recolorable_red_24.xml create mode 100644 autofill/autofill-impl/src/main/res/drawable/ic_passwords_import_128.xml create mode 100644 autofill/autofill-impl/src/main/res/drawable/ic_success_128.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/content_import_from_google_password_dialog.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_in_progress.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_result.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/content_import_google_password_pre_flow.xml create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModelTest.kt diff --git a/autofill/autofill-impl/build.gradle b/autofill/autofill-impl/build.gradle index 937214be5f02..6e5dc612d6db 100644 --- a/autofill/autofill-impl/build.gradle +++ b/autofill/autofill-impl/build.gradle @@ -72,6 +72,7 @@ dependencies { implementation AndroidX.biometric implementation "net.zetetic:android-database-sqlcipher:_" + implementation "com.facebook.shimmer:shimmer:_" // Testing dependencies testImplementation project(':common-test') diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deviceauth/AutofillAuthorizationGracePeriod.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deviceauth/AutofillAuthorizationGracePeriod.kt index 90eed5c0f602..7916ad7583c4 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deviceauth/AutofillAuthorizationGracePeriod.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deviceauth/AutofillAuthorizationGracePeriod.kt @@ -40,6 +40,16 @@ interface AutofillAuthorizationGracePeriod { */ fun recordSuccessfulAuthorization() + /** + * Requests an extended grace period. This may extend the grace period to a longer duration. + */ + fun requestExtendedGracePeriod() + + /** + * Removes the request for an extended grace period + */ + fun removeRequestForExtendedGracePeriod() + /** * Invalidates the grace period, so that the next call to [isAuthRequired] will return true */ @@ -53,12 +63,21 @@ class AutofillTimeBasedAuthorizationGracePeriod @Inject constructor( ) : AutofillAuthorizationGracePeriod { private var lastSuccessfulAuthTime: Long? = null + private var extendedGraceTimeRequested: Long? = null override fun recordSuccessfulAuthorization() { lastSuccessfulAuthTime = timeProvider.currentTimeMillis() Timber.v("Recording timestamp of successful auth") } + override fun requestExtendedGracePeriod() { + extendedGraceTimeRequested = timeProvider.currentTimeMillis() + } + + override fun removeRequestForExtendedGracePeriod() { + extendedGraceTimeRequested = null + } + override fun isAuthRequired(): Boolean { lastSuccessfulAuthTime?.let { lastAuthTime -> val timeSinceLastAuth = timeProvider.currentTimeMillis() - lastAuthTime @@ -68,16 +87,33 @@ class AutofillTimeBasedAuthorizationGracePeriod @Inject constructor( return false } } - Timber.v("No last auth time recorded or outside grace period; auth required") + if (inExtendedGracePeriod()) { + Timber.v("Within extended grace period; auth not required") + return false + } + + Timber.v("No last auth time recorded or outside grace period; auth required") return true } + private fun inExtendedGracePeriod(): Boolean { + val extendedRequest = extendedGraceTimeRequested + if (extendedRequest == null) { + return false + } else { + val timeSinceExtendedGrace = timeProvider.currentTimeMillis() - extendedRequest + return timeSinceExtendedGrace <= AUTH_GRACE_EXTENDED_PERIOD_MS + } + } + override fun invalidate() { lastSuccessfulAuthTime = null + removeRequestForExtendedGracePeriod() } companion object { private const val AUTH_GRACE_PERIOD_MS = 15_000 + private const val AUTH_GRACE_EXTENDED_PERIOD_MS = 180_000 } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt index a8faff9a2eaa..ed5ee83bc693 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordResult.kt @@ -17,6 +17,7 @@ package com.duckduckgo.autofill.impl.importing.gpm.webflow import android.os.Parcelable +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason import kotlinx.parcelize.Parcelize sealed interface ImportGooglePasswordResult : Parcelable { @@ -28,7 +29,7 @@ sealed interface ImportGooglePasswordResult : Parcelable { data class UserCancelled(val stage: String) : ImportGooglePasswordResult @Parcelize - data object Error : ImportGooglePasswordResult + data class Error(val reason: UserCannotImportReason) : ImportGooglePasswordResult companion object { const val RESULT_KEY = "importResult" diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt index 24a1842a7051..1e77d0a68bbf 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt @@ -45,7 +45,14 @@ import com.duckduckgo.autofill.impl.databinding.FragmentImportGooglePasswordsWeb import com.duckduckgo.autofill.impl.importing.blob.GooglePasswordBlobConsumer import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY_DETAILS -import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.* +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.Initializing +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.LoadStartPage +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.NavigatingBack +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedCannotImport +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedImportFlow +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.WebContentShowing import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowWebViewClient.NewPageCallback import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillCallback import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillEventListener @@ -155,7 +162,7 @@ class ImportGooglePasswordsWebFlowFragment : when (viewState) { is UserFinishedImportFlow -> exitFlowAsSuccess() is UserCancelledImportFlow -> exitFlowAsCancellation(viewState.stage) - is UserFinishedCannotImport -> exitFlowAsImpossibleToImport() + is UserFinishedCannotImport -> exitFlowAsImpossibleToImport(viewState.reason) is NavigatingBack -> binding?.webView?.goBack() is LoadStartPage -> loadFirstWebpage(viewState.initialLaunchUrl) is WebContentShowing, Initializing -> { @@ -177,9 +184,9 @@ class ImportGooglePasswordsWebFlowFragment : setFragmentResult(RESULT_KEY, resultBundle) } - private fun exitFlowAsImpossibleToImport() { + private fun exitFlowAsImpossibleToImport(reason: UserCannotImportReason) { val resultBundle = Bundle().also { - it.putParcelable(RESULT_KEY_DETAILS, ImportGooglePasswordResult.Error) + it.putParcelable(RESULT_KEY_DETAILS, ImportGooglePasswordResult.Error(reason)) } setFragmentResult(RESULT_KEY, resultBundle) } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt index 125d39083c16..6e74fc3dd801 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt @@ -16,6 +16,7 @@ package com.duckduckgo.autofill.impl.importing.gpm.webflow +import android.os.Parcelable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel @@ -23,6 +24,8 @@ import com.duckduckgo.autofill.impl.importing.CredentialImporter import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.EncryptedPassphrase +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.ErrorParsingCsv import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.Initializing import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope @@ -30,6 +33,7 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize import timber.log.Timber @ContributesViewModel(ActivityScope::class) @@ -63,12 +67,12 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( fun onCsvError() { Timber.w("Error decoding CSV") - _viewState.value = ViewState.UserFinishedCannotImport + _viewState.value = ViewState.UserFinishedCannotImport(ErrorParsingCsv) } fun onCloseButtonPressed(url: String?) { if (url?.startsWith(ENCRYPTED_PASSPHRASE_ERROR_URL) == true) { - _viewState.value = ViewState.UserFinishedCannotImport + _viewState.value = ViewState.UserFinishedCannotImport(EncryptedPassphrase) } else { terminateFlowAsCancellation(url ?: "unknown") } @@ -101,10 +105,18 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( data class LoadStartPage(val initialLaunchUrl: String) : ViewState data class UserCancelledImportFlow(val stage: String) : ViewState data object UserFinishedImportFlow : ViewState - data object UserFinishedCannotImport : ViewState + data class UserFinishedCannotImport(val reason: UserCannotImportReason) : ViewState data object NavigatingBack : ViewState } + sealed interface UserCannotImportReason : Parcelable { + @Parcelize + data object ErrorParsingCsv : UserCannotImportReason + + @Parcelize + data object EncryptedPassphrase : UserCannotImportReason + } + sealed interface BackButtonAction { data object NavigateBack : BackButtonAction } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/pixel/AutofillPixelNames.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/pixel/AutofillPixelNames.kt index 9b46fa6d9b86..bdc925101e0a 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/pixel/AutofillPixelNames.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/pixel/AutofillPixelNames.kt @@ -24,9 +24,7 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_ONBOARDED_USER import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_STACKED_LOGINS import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_COPIED_DESKTOP_LINK -import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_GET_DESKTOP_BROWSER -import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_SHARED_DESKTOP_LINK import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_SYNC_WITH_DESKTOP import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_USER_JOURNEY_RESTARTED @@ -43,6 +41,8 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAK import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_CONFIRMED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_DISMISSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_DISPLAYED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_TOOLTIP_DISMISSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ADDRESS import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ALIAS @@ -142,8 +142,8 @@ enum class AutofillPixelNames(override val pixelName: String) : Pixel.PixelName AUTOFILL_TOGGLED_ON_SEARCH("m_autofill_toggled_on"), AUTOFILL_TOGGLED_OFF_SEARCH("m_autofill_toggled_off"), - AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON("m_autofill_logins_import_no_passwords"), - AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU("m_autofill_logins_import"), + AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON("m_autofill_logins_import_no_passwords"), + AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU("m_autofill_logins_import"), AUTOFILL_IMPORT_PASSWORDS_GET_DESKTOP_BROWSER("m_autofill_logins_import_get_desktop"), AUTOFILL_IMPORT_PASSWORDS_SYNC_WITH_DESKTOP("m_autofill_logins_import_sync"), AUTOFILL_IMPORT_PASSWORDS_USER_TOOK_NO_ACTION("m_autofill_logins_import_no-action"), @@ -177,8 +177,8 @@ object AutofillPixelsRequiringDataCleaning : PixelParamRemovalPlugin { AUTOFILL_ENGAGEMENT_ONBOARDED_USER.pixelName to PixelParameter.removeAtb(), AUTOFILL_ENGAGEMENT_STACKED_LOGINS.pixelName to PixelParameter.removeAtb(), - AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON.pixelName to PixelParameter.removeAtb(), - AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU.pixelName to PixelParameter.removeAtb(), + AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON.pixelName to PixelParameter.removeAtb(), + AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU.pixelName to PixelParameter.removeAtb(), AUTOFILL_IMPORT_PASSWORDS_GET_DESKTOP_BROWSER.pixelName to PixelParameter.removeAtb(), AUTOFILL_IMPORT_PASSWORDS_SYNC_WITH_DESKTOP.pixelName to PixelParameter.removeAtb(), AUTOFILL_IMPORT_PASSWORDS_USER_TOOK_NO_ACTION.pixelName to PixelParameter.removeAtb(), diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt index 151aed68cee7..1878ed2f155d 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt @@ -20,8 +20,12 @@ import android.util.Patterns import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.DocumentStartJavaScript +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.WebMessageListener import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserOverflow import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserSnackbar @@ -84,7 +88,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsVie import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.DuckAddressStatus.NotManageable import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.DuckAddressStatus.SettingActivationStatus import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchDeleteAllPasswordsConfirmation -import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswords +import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswordsFromGooglePasswordManager import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchReportAutofillBreakageConfirmation import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchResetNeverSaveListConfirmation import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.PromptUserToAuthenticateMassDeletion @@ -133,6 +137,8 @@ class AutofillSettingsViewModel @Inject constructor( private val autofillBreakageReportSender: AutofillBreakageReportSender, private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore, private val autofillBreakageReportCanShowRules: AutofillBreakageReportCanShowRules, + private val autofillFeature: AutofillFeature, + private val webViewCapabilityChecker: WebViewCapabilityChecker, ) : ViewModel() { private val _viewState = MutableStateFlow(ViewState()) @@ -160,6 +166,9 @@ class AutofillSettingsViewModel @Inject constructor( private var combineJob: Job? = null + // we only want to send this once for this 'session' of being in the management screen + private var importGooglePasswordButtonShownPixelSent = false + fun onCopyUsername(username: String?) { username?.let { clipboardInteractor.copyToClipboard(it, isSensitive = false) } pixel.fire(AutofillPixelNames.AUTOFILL_COPY_USERNAME) @@ -431,6 +440,14 @@ class AutofillSettingsViewModel @Inject constructor( _neverSavedSitesViewState.value = NeverSavedSitesViewState(showOptionToReset = count > 0) } } + + viewModelScope.launch(dispatchers.io()) { + val gpmImport = autofillFeature.self().isEnabled() && autofillFeature.canImportFromGooglePasswordManager().isEnabled() + val webViewWebMessageSupport = webViewCapabilityChecker.isSupported(WebMessageListener) + val webViewDocumentStartJavascript = webViewCapabilityChecker.isSupported(DocumentStartJavaScript) + val canImport = gpmImport && webViewWebMessageSupport && webViewDocumentStartJavascript + _viewState.value = _viewState.value.copy(canImportFromGooglePasswords = canImport) + } } private suspend fun isBreakageReportingAllowed(): Boolean { @@ -689,8 +706,10 @@ class AutofillSettingsViewModel @Inject constructor( } } - fun onImportPasswords() { - addCommand(LaunchImportPasswords) + fun onImportPasswordsFromGooglePasswordManager() { + viewModelScope.launch(dispatchers.io()) { + addCommand(LaunchImportPasswordsFromGooglePasswordManager) + } } fun onReportBreakageClicked() { @@ -702,7 +721,10 @@ class AutofillSettingsViewModel @Inject constructor( } } - fun updateCurrentSite(currentUrl: String?, privacyProtectionEnabled: Boolean?) { + fun updateCurrentSite( + currentUrl: String?, + privacyProtectionEnabled: Boolean?, + ) { val updatedReportBreakageState = _viewState.value.reportBreakageState.copy( currentUrl = currentUrl, privacyProtectionEnabled = privacyProtectionEnabled, @@ -771,6 +793,14 @@ class AutofillSettingsViewModel @Inject constructor( } } + fun recordImportGooglePasswordButtonShown() { + if (!importGooglePasswordButtonShownPixelSent) { + importGooglePasswordButtonShownPixelSent = true + + // pixel to show import button would fire here + } + } + data class ViewState( val autofillEnabled: Boolean = true, val showAutofillEnabledToggle: Boolean = true, @@ -779,6 +809,7 @@ class AutofillSettingsViewModel @Inject constructor( val credentialSearchQuery: String = "", val reportBreakageState: ReportBreakageState = ReportBreakageState(), val canShowPromo: Boolean = false, + val canImportFromGooglePasswords: Boolean = false, ) data class ReportBreakageState( @@ -854,7 +885,7 @@ class AutofillSettingsViewModel @Inject constructor( data object LaunchResetNeverSaveListConfirmation : ListModeCommand() data class LaunchDeleteAllPasswordsConfirmation(val numberToDelete: Int) : ListModeCommand() data class PromptUserToAuthenticateMassDeletion(val authConfiguration: AuthConfiguration) : ListModeCommand() - data object LaunchImportPasswords : ListModeCommand() + data object LaunchImportPasswordsFromGooglePasswordManager : ListModeCommand() data class LaunchReportAutofillBreakageConfirmation(val eTldPlusOne: String) : ListModeCommand() data object ShowUserReportSentMessage : ListModeCommand() data object ReevalutePromotions : ListModeCommand() diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialog.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialog.kt new file mode 100644 index 000000000000..3a533ea8fa5c --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialog.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google + +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.ContentImportFromGooglePasswordDialogBinding +import com.duckduckgo.autofill.impl.deviceauth.AutofillAuthorizationGracePeriod +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePassword +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult +import com.duckduckgo.autofill.impl.ui.credential.dialog.animateClosed +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.ImportError +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.ImportSuccess +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.Importing +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.PreImport +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewState +import com.duckduckgo.common.utils.FragmentViewModelFactory +import com.duckduckgo.common.utils.extensions.html +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.navigation.api.GlobalActivityStarter +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 javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +@InjectWith(FragmentScope::class) +class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { + + @Inject + lateinit var pixel: Pixel + + /** + * 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 + + @Inject + lateinit var faviconManager: FaviconManager + + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + + @Inject + lateinit var authorizationGracePeriod: AutofillAuthorizationGracePeriod + + private var _binding: ContentImportFromGooglePasswordDialogBinding? = null + + private val binding get() = _binding!! + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + private val viewModel by bindViewModel() + + private val importGooglePasswordsFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + activityResult.data?.let { data -> + processImportFlowResult(data) + } + } + } + } + } + + private fun ImportFromGooglePasswordsDialog.processImportFlowResult(data: Intent) { + (IntentCompat.getParcelableExtra(data, ImportGooglePasswordResult.RESULT_KEY_DETAILS, ImportGooglePasswordResult::class.java)).let { + when (it) { + is ImportGooglePasswordResult.Success -> viewModel.onImportFlowFinishedSuccessfully() + is ImportGooglePasswordResult.Error -> viewModel.onImportFlowFinishedWithError(it.reason) + is ImportGooglePasswordResult.UserCancelled -> viewModel.onImportFlowCancelledByUser() + else -> {} + } + } + } + + private fun switchDialogShowImportInProgressView() { + binding.prePostViewSwitcher.displayedChild = 1 + binding.postflow.inProgressFinishedViewSwitcher.displayedChild = 0 + } + + private fun switchDialogShowImportResultsView() { + binding.prePostViewSwitcher.displayedChild = 1 + binding.postflow.inProgressFinishedViewSwitcher.displayedChild = 1 + } + + private fun switchDialogShowPreImportView() { + binding.prePostViewSwitcher.displayedChild = 0 + } + + private fun processSuccessResult(result: CredentialImporter.ImportResult.Finished) { + binding.postflow.importFinished.errorNotImported.visibility = View.GONE + binding.postflow.appIcon.setImageDrawable( + ContextCompat.getDrawable( + binding.root.context, + R.drawable.ic_success_128, + ), + ) + binding.postflow.dialogTitle.text = getString(R.string.importPasswordsProcessingResultDialogTitleUponSuccess) + + with(binding.postflow.importFinished.resultsImported) { + val output = getString(R.string.importPasswordsProcessingResultDialogResultPasswordsImported, result.savedCredentials) + setPrimaryText(output.html(binding.root.context)) + } + + with(binding.postflow.importFinished.duplicatesNotImported) { + val output = getString(R.string.importPasswordsProcessingResultDialogResultDuplicatesSkipped, result.numberSkipped) + setPrimaryText(output.html(binding.root.context)) + visibility = if (result.numberSkipped > 0) View.VISIBLE else View.GONE + } + + switchDialogShowImportResultsView() + } + + private fun processErrorResult() { + binding.postflow.importFinished.resultsImported.visibility = View.GONE + binding.postflow.importFinished.duplicatesNotImported.visibility = View.GONE + binding.postflow.importFinished.errorNotImported.visibility = View.VISIBLE + + binding.postflow.dialogTitle.text = getString(R.string.importPasswordsProcessingResultDialogTitleBeforeSuccess) + binding.postflow.appIcon.setImageDrawable( + ContextCompat.getDrawable( + binding.root.context, + R.drawable.ic_passwords_import_128, + ), + ) + + switchDialogShowImportResultsView() + } + + 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() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = ContentImportFromGooglePasswordDialogBinding.inflate(inflater, container, false) + configureViews(binding) + observeViewModel() + return binding.root + } + + private fun observeViewModel() { + viewModel.viewState.flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { viewState -> renderViewState(viewState) } + .launchIn(lifecycleScope) + } + + private fun renderViewState(viewState: ViewState) { + when (viewState.viewMode) { + is PreImport -> switchDialogShowPreImportView() + is ImportError -> processErrorResult() + is ImportSuccess -> processSuccessResult(viewState.viewMode.importResult) + is Importing -> switchDialogShowImportInProgressView() + } + } + + override fun onDestroyView() { + _binding = null + authorizationGracePeriod.removeRequestForExtendedGracePeriod() + super.onDestroyView() + } + + private fun configureViews(binding: ContentImportFromGooglePasswordDialogBinding) { + (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED + configureCloseButton(binding) + + with(binding.preflow.importGcmButton) { + setOnClickListener { onImportGcmButtonClicked() } + } + + with(binding.postflow.importFinished.primaryCtaButton) { + setOnClickListener { + dismiss() + } + } + } + + private fun onImportGcmButtonClicked() { + launchImportGcmFlow() + } + + private fun launchImportGcmFlow() { + authorizationGracePeriod.requestExtendedGracePeriod() + + val intent = globalActivityStarter.startIntent( + requireContext(), + ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen, + ) + importGooglePasswordsFlowLauncher.launch(intent) + } + + override fun onCancel(dialog: DialogInterface) { + if (ignoreCancellationEvents) { + Timber.v("onCancel: Ignoring cancellation event") + return + } + dismiss() + } + + private fun configureCloseButton(binding: ContentImportFromGooglePasswordDialogBinding) { + binding.closeButton.setOnClickListener { (dialog as BottomSheetDialog).animateClosed() } + } + + private inline fun bindViewModel() = lazy { ViewModelProvider(this, viewModelFactory)[V::class.java] } + + companion object { + + fun instance(): ImportFromGooglePasswordsDialog { + val fragment = ImportFromGooglePasswordsDialog() + return fragment + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModel.kt new file mode 100644 index 000000000000..1ab1a1860cf1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModel.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.Importing +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.PreImport +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +@ContributesViewModel(FragmentScope::class) +class ImportFromGooglePasswordsDialogViewModel @Inject constructor( + private val credentialImporter: CredentialImporter, + private val dispatchers: DispatcherProvider, +) : ViewModel() { + + fun onImportFlowFinishedSuccessfully() { + viewModelScope.launch(dispatchers.main()) { + observeImportJob() + } + } + + private suspend fun observeImportJob() { + credentialImporter.getImportStatus().collect { + when (it) { + is ImportResult.InProgress -> { + Timber.d("Import in progress") + _viewState.value = ViewState(viewMode = Importing) + } + + is ImportResult.Finished -> { + Timber.d("Import finished: ${it.savedCredentials} imported. ${it.numberSkipped} skipped.") + _viewState.value = ViewState(viewMode = ViewMode.ImportSuccess(it)) + } + } + } + } + + fun onImportFlowFinishedWithError(reason: UserCannotImportReason) { + _viewState.value = ViewState(viewMode = ViewMode.ImportError) + } + + fun onImportFlowCancelledByUser() { + } + + private val _viewState = MutableStateFlow(ViewState()) + val viewState: StateFlow = _viewState + + data class ViewState(val viewMode: ViewMode = PreImport) + + sealed interface ViewMode { + data object PreImport : ViewMode + data object Importing : ViewMode + data class ImportSuccess(val importResult: ImportResult.Finished) : ViewMode + data object ImportError : ViewMode + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt index 620118de9b5e..8f705057012f 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt @@ -47,8 +47,8 @@ import com.duckduckgo.autofill.impl.databinding.FragmentAutofillManagementListMo import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator.AuthConfiguration import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator.AuthResult.Success -import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON -import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementActivity import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementRecyclerAdapter import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementRecyclerAdapter.ContextMenuAction.CopyPassword @@ -57,13 +57,15 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementR import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementRecyclerAdapter.ContextMenuAction.Edit import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchDeleteAllPasswordsConfirmation -import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswords +import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswordsFromGooglePasswordManager import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchReportAutofillBreakageConfirmation import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchResetNeverSaveListConfirmation import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.PromptUserToAuthenticateMassDeletion import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.ReevalutePromotions import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.ShowUserReportSentMessage +import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ViewState import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordActivityParams +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialog import com.duckduckgo.autofill.impl.ui.credential.management.sorting.CredentialGrouper import com.duckduckgo.autofill.impl.ui.credential.management.sorting.InitialExtractor import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionListBuilder @@ -143,7 +145,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill private var searchMenuItem: MenuItem? = null private var resetNeverSavedSitesMenuItem: MenuItem? = null private var deleteAllPasswordsMenuItem: MenuItem? = null - private var importPasswordsMenuItem: MenuItem? = null + private var syncDesktopPasswordsMenuItem: MenuItem? = null + private var importGooglePasswordsMenuItem: MenuItem? = null private val globalAutofillToggleListener = CompoundButton.OnCheckedChangeListener { _, isChecked -> if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return@OnCheckedChangeListener @@ -241,9 +244,13 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill } private fun configureImportPasswordsButton() { - binding.emptyStateLayout.importPasswordsButton.setOnClickListener { - viewModel.onImportPasswords() - pixel.fire(AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON) + binding.emptyStateLayout.importPasswordsFromGoogleButton.setOnClickListener { + viewModel.onImportPasswordsFromGooglePasswordManager() + } + + binding.emptyStateLayout.importPasswordsViaDesktopSyncButton.setOnClickListener { + launchImportPasswordsFromDesktopSyncScreen() + pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON) } } @@ -258,7 +265,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill searchMenuItem = menu.findItem(R.id.searchLogins) resetNeverSavedSitesMenuItem = menu.findItem(R.id.resetNeverSavedSites) deleteAllPasswordsMenuItem = menu.findItem(R.id.deleteAllPasswords) - importPasswordsMenuItem = menu.findItem(R.id.importPasswords) + syncDesktopPasswordsMenuItem = menu.findItem(R.id.syncDesktopPasswords) + importGooglePasswordsMenuItem = menu.findItem(R.id.importGooglePasswords) initializeSearchBar() } @@ -268,7 +276,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill searchMenuItem?.isVisible = loginsSaved deleteAllPasswordsMenuItem?.isVisible = loginsSaved resetNeverSavedSitesMenuItem?.isVisible = viewModel.neverSavedSitesViewState.value.showOptionToReset - importPasswordsMenuItem?.isVisible = loginsSaved + syncDesktopPasswordsMenuItem?.isVisible = loginsSaved + importGooglePasswordsMenuItem?.isVisible = loginsSaved && viewModel.viewState.value.canImportFromGooglePasswords } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { @@ -288,9 +297,14 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill true } - R.id.importPasswords -> { - viewModel.onImportPasswords() - pixel.fire(AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU) + R.id.importGooglePasswords -> { + viewModel.onImportPasswordsFromGooglePasswordManager() + true + } + + R.id.syncDesktopPasswords -> { + launchImportPasswordsFromDesktopSyncScreen() + pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU) true } @@ -336,7 +350,12 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill viewModel.viewState.collect { state -> binding.enabledToggle.quietlySetIsChecked(state.autofillEnabled, globalAutofillToggleListener) state.logins?.let { - credentialsListUpdated(it, state.credentialSearchQuery, state.reportBreakageState.allowBreakageReporting) + credentialsListUpdated( + credentials = it, + credentialSearchQuery = state.credentialSearchQuery, + allowBreakageReporting = state.reportBreakageState.allowBreakageReporting, + canShowImportGooglePasswordsButton = state.canImportFromGooglePasswords, + ) parentActivity()?.invalidateOptionsMenu() } @@ -364,9 +383,28 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill } } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.collect { + // we can just invalidate the menu as [onPrepareMenu] will handle the new visibility for importing passwords menu item + parentActivity()?.invalidateOptionsMenu() + + configureImportPasswordsButtonVisibility(it) + } + } + } + viewModel.onViewCreated() } + private fun configureImportPasswordsButtonVisibility(state: ViewState) { + if (state.canImportFromGooglePasswords) { + binding.emptyStateLayout.importPasswordsFromGoogleButton.show() + } else { + binding.emptyStateLayout.importPasswordsFromGoogleButton.gone() + } + } + private fun observeListModeViewModelCommands() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(State.STARTED) { @@ -382,7 +420,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill LaunchResetNeverSaveListConfirmation -> launchResetNeverSavedSitesConfirmation() is LaunchDeleteAllPasswordsConfirmation -> launchDeleteAllLoginsConfirmationDialog(command.numberToDelete) is PromptUserToAuthenticateMassDeletion -> promptUserToAuthenticateMassDeletion(command.authConfiguration) - is LaunchImportPasswords -> launchImportPasswordsScreen() + is LaunchImportPasswordsFromGooglePasswordManager -> launchImportPasswordsScreen() is LaunchReportAutofillBreakageConfirmation -> launchReportBreakageConfirmation(command.eTldPlusOne) is ShowUserReportSentMessage -> showUserReportSentMessage() is ReevalutePromotions -> configurePromotionsContainer() @@ -395,6 +433,13 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill } private fun launchImportPasswordsScreen() { + context?.let { + val dialog = ImportFromGooglePasswordsDialog.instance() + dialog.show(parentFragmentManager, IMPORT_FROM_GPM_DIALOG_TAG) + } + } + + private fun launchImportPasswordsFromDesktopSyncScreen() { context?.let { globalActivityStarter.start(it, ImportPasswordActivityParams) } @@ -438,9 +483,10 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill credentials: List, credentialSearchQuery: String, allowBreakageReporting: Boolean, + canShowImportGooglePasswordsButton: Boolean, ) { if (credentials.isEmpty() && credentialSearchQuery.isEmpty()) { - showEmptyCredentialsPlaceholders() + showEmptyCredentialsPlaceholders(canShowImportGooglePasswordsButton) } else if (credentials.isEmpty()) { showNoResultsPlaceholders(credentialSearchQuery) } else { @@ -454,10 +500,12 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill adapter.showNoMatchingSearchResults(query) } - private fun showEmptyCredentialsPlaceholders() { + private fun showEmptyCredentialsPlaceholders(canShowImportGooglePasswordsButton: Boolean) { binding.emptyStateLayout.emptyStateContainer.show() - binding.logins.gone() + if (canShowImportGooglePasswordsButton) { + viewModel.recordImportGooglePasswordButtonShown() + } } private suspend fun renderCredentialList( @@ -602,7 +650,11 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill } companion object { - fun instance(currentUrl: String? = null, privacyProtectionEnabled: Boolean?, source: AutofillSettingsLaunchSource? = null) = + fun instance( + currentUrl: String? = null, + privacyProtectionEnabled: Boolean?, + source: AutofillSettingsLaunchSource? = null, + ) = AutofillManagementListMode().apply { arguments = Bundle().apply { putString(ARG_CURRENT_URL, currentUrl) @@ -621,6 +673,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill private const val ARG_PRIVACY_PROTECTION_STATUS = "ARG_PRIVACY_PROTECTION_STATUS" private const val ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE = "ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE" private const val LEARN_MORE_LINK = "https://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/" + private const val IMPORT_FROM_GPM_DIALOG_TAG = "IMPORT_FROM_GPM_DIALOG_TAG" } } diff --git a/autofill/autofill-impl/src/main/res/drawable/autofill_gpm_export_instruction.xml b/autofill/autofill-impl/src/main/res/drawable/autofill_gpm_export_instruction.xml new file mode 100644 index 000000000000..a23100c2710f --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/autofill_gpm_export_instruction.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/autofill_rounded_border_import_background.xml b/autofill/autofill-impl/src/main/res/drawable/autofill_rounded_border_import_background.xml new file mode 100644 index 000000000000..b7a9c542829f --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/autofill_rounded_border_import_background.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_check_recolorable_24.xml b/autofill/autofill-impl/src/main/res/drawable/ic_check_recolorable_24.xml new file mode 100644 index 000000000000..3e79b3d20fbe --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_check_recolorable_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_cross_recolorable_red_24.xml b/autofill/autofill-impl/src/main/res/drawable/ic_cross_recolorable_red_24.xml new file mode 100644 index 000000000000..432454a57240 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_cross_recolorable_red_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_passwords_import_128.xml b/autofill/autofill-impl/src/main/res/drawable/ic_passwords_import_128.xml new file mode 100644 index 000000000000..bcc09c00bf5e --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_passwords_import_128.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_success_128.xml b/autofill/autofill-impl/src/main/res/drawable/ic_success_128.xml new file mode 100644 index 000000000000..722ef58f645f --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_success_128.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/layout/autofill_management_credential_list_empty_state.xml b/autofill/autofill-impl/src/main/res/layout/autofill_management_credential_list_empty_state.xml index c234aed93668..e9dc191c4343 100644 --- a/autofill/autofill-impl/src/main/res/layout/autofill_management_credential_list_empty_state.xml +++ b/autofill/autofill-impl/src/main/res/layout/autofill_management_credential_list_empty_state.xml @@ -66,20 +66,32 @@ android:layout_marginTop="@dimen/keyline_2" android:layout_marginStart="@dimen/keyline_6" android:layout_marginEnd="@dimen/keyline_6" + android:paddingBottom="@dimen/keyline_5" app:layout_constraintWidth_max="300dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/emptyPlaceholderTitle" android:text="@string/credentialManagementNoLoginsSavedSubtitle" /> + + + android:text="@string/autofillSyncDesktopPasswordEmptyStateButtonTitle" /> diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_from_google_password_dialog.xml b/autofill/autofill-impl/src/main/res/layout/content_import_from_google_password_dialog.xml new file mode 100644 index 000000000000..17e894b84d25 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_from_google_password_dialog.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow.xml b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow.xml new file mode 100644 index 000000000000..94e5109a2fa4 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_in_progress.xml b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_in_progress.xml new file mode 100644 index 000000000000..c2307128fc73 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_in_progress.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_result.xml b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_result.xml new file mode 100644 index 000000000000..2e48cbafdf4a --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow_result.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/layout/content_import_google_password_pre_flow.xml b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_pre_flow.xml new file mode 100644 index 000000000000..b5cc4d9e9de9 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_pre_flow.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/menu/autofill_list_mode_menu.xml b/autofill/autofill-impl/src/main/res/menu/autofill_list_mode_menu.xml index d8b20f9b45df..ca4aecf0ee4d 100644 --- a/autofill/autofill-impl/src/main/res/menu/autofill_list_mode_menu.xml +++ b/autofill/autofill-impl/src/main/res/menu/autofill_list_mode_menu.xml @@ -40,15 +40,21 @@ app:showAsAction="never" /> + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/values/donottranslate.xml b/autofill/autofill-impl/src/main/res/values/donottranslate.xml index fa2047cdba76..882500b8984b 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -20,4 +20,25 @@ Import Google Passwords %1$d passwords imported from Google + %1$d passwords imported from CSV + + Import Passwords From Google + Import Passwords From Google + + Sync DuckDuckGo Passwords + Sync DuckDuckGo Passwords + + Import Your Google Passwords + Google may ask you to sign in or enter your password to confirm. + Open Google Passwords + Choose a CSV file + Import from Desktop Browser + + Import to DuckDuckGo + Import Complete + Got It + Password import failed + Skipped (duplicate or invalid): %d]]> + Passwords imported: %d]]> + \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/AutofillTimeBasedAuthorizationGracePeriodTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/AutofillTimeBasedAuthorizationGracePeriodTest.kt index 7a386da11c21..05796db0e288 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/AutofillTimeBasedAuthorizationGracePeriodTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/AutofillTimeBasedAuthorizationGracePeriodTest.kt @@ -55,6 +55,39 @@ class AutofillTimeBasedAuthorizationGracePeriodTest { assertTrue(testee.isAuthRequired()) } + @Test + fun whenLastSuccessfulAuthWasBeforeGracePeriodButWithinExtendedAuthTimeThenAuthNotRequired() { + recordAuthorizationInDistantPast() + timeProvider.reset() + testee.requestExtendedGracePeriod() + assertFalse(testee.isAuthRequired()) + } + + @Test + fun whenNoPreviousAuthButWithinExtendedAuthTimeThenAuthNotRequired() { + testee.requestExtendedGracePeriod() + assertFalse(testee.isAuthRequired()) + } + + @Test + fun whenExtendedAuthTimeRequestedButTooLongAgoThenAuthRequired() { + configureExtendedAuthRequestedInDistantPast() + timeProvider.reset() + assertTrue(testee.isAuthRequired()) + } + + @Test + fun whenExtendedAuthTimeRequestedAndThenRemovedThenAuthRequired() { + testee.requestExtendedGracePeriod() + testee.removeRequestForExtendedGracePeriod() + assertTrue(testee.isAuthRequired()) + } + + private fun configureExtendedAuthRequestedInDistantPast() { + whenever(timeProvider.currentTimeMillis()).thenReturn(0) + testee.requestExtendedGracePeriod() + } + private fun recordAuthorizationInDistantPast() { whenever(timeProvider.currentTimeMillis()).thenReturn(0) testee.recordSuccessfulAuthorization() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt index 3c7b7accf969..54b906e09b98 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt @@ -16,11 +16,16 @@ package com.duckduckgo.autofill.impl.ui.credential.management +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.DocumentStartJavaScript +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.WebMessageListener import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count +import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserOverflow import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserSnackbar @@ -78,6 +83,8 @@ import com.duckduckgo.autofill.impl.ui.credential.management.viewing.duckaddress import com.duckduckgo.autofill.impl.ui.credential.repository.DuckAddressStatusRepository import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import kotlin.reflect.KClass import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -99,6 +106,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") @RunWith(AndroidJUnit4::class) class AutofillSettingsViewModelTest { @@ -121,6 +129,8 @@ class AutofillSettingsViewModelTest { private val autofillBreakageReportCanShowRules: AutofillBreakageReportCanShowRules = mock() private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore = mock() private val urlMatcher = AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl()) + private val webViewCapabilityChecker: WebViewCapabilityChecker = mock() + private val autofillFeature = FakeFeatureToggleFactory.create(AutofillFeature::class.java) private val testee = AutofillSettingsViewModel( autofillStore = mockStore, @@ -140,6 +150,8 @@ class AutofillSettingsViewModelTest { autofillBreakageReportSender = autofillBreakageReportSender, autofillBreakageReportDataStore = autofillBreakageReportDataStore, autofillBreakageReportCanShowRules = autofillBreakageReportCanShowRules, + webViewCapabilityChecker = webViewCapabilityChecker, + autofillFeature = autofillFeature, ) @Before @@ -151,6 +163,10 @@ class AutofillSettingsViewModelTest { whenever(mockStore.getCredentialCount()).thenReturn(flowOf(0)) whenever(neverSavedSiteRepository.neverSaveListCount()).thenReturn(emptyFlow()) whenever(deviceAuthenticator.isAuthenticationRequiredForAutofill()).thenReturn(true) + whenever(webViewCapabilityChecker.isSupported(WebMessageListener)).thenReturn(true) + whenever(webViewCapabilityChecker.isSupported(DocumentStartJavaScript)).thenReturn(true) + autofillFeature.self().setRawStoredState(State(enable = true)) + autofillFeature.canImportFromGooglePasswordManager().setRawStoredState(State(enable = true)) } } @@ -922,6 +938,45 @@ class AutofillSettingsViewModelTest { verify(pixel).fire(AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_DISMISSED) } + @Test + fun whenImportGooglePasswordsIsEnabledThenViewStateReflectsThat() = runTest { + testee.onViewCreated() + testee.viewState.test { + assertTrue(awaitItem().canImportFromGooglePasswords) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenImportGooglePasswordsFeatureFlagDisabledThenViewStateReflectsThat() = runTest { + autofillFeature.canImportFromGooglePasswordManager().setRawStoredState(State(enable = false)) + testee.onViewCreated() + testee.viewState.test { + assertFalse(awaitItem().canImportFromGooglePasswords) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenImportGooglePasswordsFeatureDisabledDueToWebMessageListenerNotSupportedThenViewStateReflectsThat() = runTest { + whenever(webViewCapabilityChecker.isSupported(WebMessageListener)).thenReturn(false) + testee.onViewCreated() + testee.viewState.test { + assertFalse(awaitItem().canImportFromGooglePasswords) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenImportGooglePasswordsFeatureDisabledDueToDocumentStartJavascriptNotSupportedThenViewStateReflectsThat() = runTest { + whenever(webViewCapabilityChecker.isSupported(DocumentStartJavaScript)).thenReturn(false) + testee.onViewCreated() + testee.viewState.test { + assertFalse(awaitItem().canImportFromGooglePasswords) + cancelAndIgnoreRemainingEvents() + } + } + private fun List.verifyHasCommandToShowDeleteAllConfirmation(expectedNumberOfCredentialsToDelete: Int) { val confirmationCommand = this.firstOrNull { it is LaunchDeleteAllPasswordsConfirmation } assertNotNull(confirmationCommand) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModelTest.kt new file mode 100644 index 000000000000..19b4db995c84 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/google/ImportFromGooglePasswordsDialogViewModelTest.kt @@ -0,0 +1,141 @@ +package com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google + +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.InProgress +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.EncryptedPassphrase +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.ErrorParsingCsv +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.ImportSuccess +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.Importing +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.PreImport +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewState +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ImportFromGooglePasswordsDialogViewModelTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule(StandardTestDispatcher()) + + private val credentialImporter: CredentialImporter = mock() + private val testee = ImportFromGooglePasswordsDialogViewModel( + credentialImporter = credentialImporter, + dispatchers = coroutineTestRule.testDispatcherProvider, + ) + + @Before + fun setup() = runTest { + whenever(credentialImporter.getImportStatus()).thenReturn(emptyFlow()) + } + + @Test + fun whenParsingErrorOnImportThenViewModeUpdatedToError() = runTest { + testee.onImportFlowFinishedWithError(ErrorParsingCsv) + testee.viewState.test { + assertTrue(awaitItem().viewMode is ViewMode.ImportError) + } + } + + @Test + fun whenEncryptionErrorOnImportThenViewModeUpdatedToError() = runTest { + testee.onImportFlowFinishedWithError(EncryptedPassphrase) + testee.viewState.test { + assertTrue(awaitItem().viewMode is ViewMode.ImportError) + } + } + + @Test + fun whenSuccessfulImportThenViewModeUpdatedToInProgress() = runTest { + configureImportInProgress() + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + awaitImportInProgress() + } + } + + @Test + fun whenSuccessfulImportFlowThenImportFinishesNothingImportedThenViewModeUpdatedToResults() = runTest { + configureImportFinished(savedCredentials = 0, numberSkipped = 0) + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + awaitImportSuccess() + } + } + + @Test + fun whenSuccessfulImportFlowThenImportFinishesCredentialsImportedNoDuplicatesThenViewModeUpdatedToResults() = runTest { + configureImportFinished(savedCredentials = 10, numberSkipped = 0) + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + val result = awaitImportSuccess() + assertEquals(10, result.importResult.savedCredentials) + assertEquals(0, result.importResult.numberSkipped) + } + } + + @Test + fun whenSuccessfulImportFlowThenImportFinishesOnlyDuplicatesThenViewModeUpdatedToResults() = runTest { + configureImportFinished(savedCredentials = 0, numberSkipped = 2) + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + val result = awaitImportSuccess() + assertEquals(0, result.importResult.savedCredentials) + assertEquals(2, result.importResult.numberSkipped) + } + } + + @Test + fun whenSuccessfulImportNoUpdatesThenThenViewModeFirstInitialisedToPreImport() = runTest { + testee.onImportFlowFinishedSuccessfully() + testee.viewState.test { + awaitItem().assertIsPreImport() + } + } + + private fun configureImportInProgress() { + whenever(credentialImporter.getImportStatus()).thenReturn(listOf(InProgress).asFlow()) + } + + private fun configureImportFinished( + savedCredentials: Int, + numberSkipped: Int, + ) { + whenever(credentialImporter.getImportStatus()).thenReturn( + listOf( + InProgress, + Finished(savedCredentials = savedCredentials, numberSkipped = numberSkipped), + ).asFlow(), + ) + } + + private suspend fun TurbineTestContext.awaitImportSuccess(): ImportSuccess { + awaitItem().assertIsPreImport() + awaitItem().assertIsImporting() + return awaitItem().viewMode as ImportSuccess + } + + private suspend fun TurbineTestContext.awaitImportInProgress(): Importing { + awaitItem().assertIsPreImport() + return awaitItem().viewMode as Importing + } + + private fun ViewState.assertIsPreImport() { + assertTrue(viewMode is PreImport) + } + + private fun ViewState.assertIsImporting() { + assertTrue(viewMode is Importing) + } +} 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 77564c4fe5cb..d39e5856570f 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 @@ -159,7 +159,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } is CsvCredentialImportResult.Error -> { - "Failed to import passwords due to an error".showSnackbar() + FAILED_IMPORT_GENERIC_ERROR.showSnackbar() } } } @@ -173,12 +173,8 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { if (result.resultCode == Activity.RESULT_OK) { result.data?.let { when (IntentCompat.getParcelableExtra(it, RESULT_KEY_DETAILS, ImportGooglePasswordResult::class.java)) { - is Success -> { - observePasswordInputUpdates() - } - Error -> { - "Failed to import passwords due to an error".showSnackbar() - } + is Success -> observePasswordInputUpdates() + is Error -> FAILED_IMPORT_GENERIC_ERROR.showSnackbar() is UserCancelled, null -> { } } @@ -600,6 +596,8 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { return Intent(context, AutofillInternalSettingsActivity::class.java) } + private const val FAILED_IMPORT_GENERIC_ERROR = "Failed to import passwords due to an error" + private val sampleUrlList = listOf( "fill.dev", "duckduckgo.com",