From a6d17370024a47cfd7fb5379813b6d0e7fb6c01a Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Mon, 30 Sep 2024 16:40:09 +0100 Subject: [PATCH] Launch import flow from Password management screen --- autofill/autofill-impl/build.gradle | 1 + .../AutofillAuthorizationGracePeriod.kt | 29 ++ .../autofill/impl/pixel/AutofillPixelNames.kt | 12 +- .../management/AutofillSettingsViewModel.kt | 25 +- .../viewing/AutofillManagementListMode.kt | 81 +++++- .../SelectImportPasswordMethodDialog.kt | 273 ++++++++++++++++++ .../autofill_gpm_export_instruction.xml | 46 +++ .../res/drawable/ic_check_recolorable_24.xml | 14 + .../drawable/ic_cross_recolorable_red_24.xml | 13 + .../res/drawable/ic_passwords_import_128.xml | 57 ++++ ...management_credential_list_empty_state.xml | 14 +- .../content_choose_import_password_method.xml | 65 +++++ ...ntent_import_google_password_post_flow.xml | 104 +++++++ ...ontent_import_google_password_pre_flow.xml | 76 +++++ .../main/res/menu/autofill_list_mode_menu.xml | 12 +- .../src/main/res/values/donottranslate.xml | 20 ++ .../AutofillInternalSettingsActivity.kt | 6 +- 17 files changed, 820 insertions(+), 28 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/SelectImportPasswordMethodDialog.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/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/layout/content_choose_import_password_method.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_pre_flow.xml diff --git a/autofill/autofill-impl/build.gradle b/autofill/autofill-impl/build.gradle index 8ccbc43a69fc..bad937238f70 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 "org.mockito.kotlin:mockito-kotlin:_" 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..5a81d5f2cccb 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,11 @@ interface AutofillAuthorizationGracePeriod { */ fun recordSuccessfulAuthorization() + /** + * Requests an extended grace period. This may extend the grace period to a longer duration. + */ + fun requestExtendedGracePeriod() + /** * Invalidates the grace period, so that the next call to [isAuthRequired] will return true */ @@ -53,12 +58,17 @@ 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 isAuthRequired(): Boolean { lastSuccessfulAuthTime?.let { lastAuthTime -> val timeSinceLastAuth = timeProvider.currentTimeMillis() - lastAuthTime @@ -67,17 +77,36 @@ class AutofillTimeBasedAuthorizationGracePeriod @Inject constructor( Timber.v("Within grace period; auth not required") return false } + + if (inExtendedGracePeriod()) { + Timber.v("Within extended grace period; auth not required") + return false + } } + + extendedGraceTimeRequested = null 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 + extendedGraceTimeRequested = null } companion object { private const val AUTH_GRACE_PERIOD_MS = 15_000 + private const val AUTH_GRACE_EXTENDED_PERIOD_MS = 60_000 } } 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..fb9683631aa1 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 @@ -16,6 +16,7 @@ package com.duckduckgo.autofill.impl.ui.credential.management +import android.os.Parcelable import android.util.Patterns import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -36,6 +37,7 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator.AuthConfiguration +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordsFeature import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_DELETE_LOGIN import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_DISABLED @@ -112,6 +114,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize import timber.log.Timber @ContributesViewModel(ActivityScope::class) @@ -133,6 +136,7 @@ class AutofillSettingsViewModel @Inject constructor( private val autofillBreakageReportSender: AutofillBreakageReportSender, private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore, private val autofillBreakageReportCanShowRules: AutofillBreakageReportCanShowRules, + private val importPasswordsFeature: AutofillImportPasswordsFeature, ) : ViewModel() { private val _viewState = MutableStateFlow(ViewState()) @@ -690,7 +694,13 @@ class AutofillSettingsViewModel @Inject constructor( } fun onImportPasswords() { - addCommand(LaunchImportPasswords) + viewModelScope.launch(dispatchers.io()) { + with(importPasswordsFeature) { + val gpmImport = self().isEnabled() && canImportFromGooglePasswordManager().isEnabled() + val importConfig = ImportPasswordConfig(canImportFromGooglePasswordManager = gpmImport) + addCommand(LaunchImportPasswords(importConfig)) + } + } } fun onReportBreakageClicked() { @@ -702,7 +712,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, @@ -854,12 +867,18 @@ 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 class LaunchImportPasswords(val config: ImportPasswordConfig) : ListModeCommand() + data class LaunchReportAutofillBreakageConfirmation(val eTldPlusOne: String) : ListModeCommand() data object ShowUserReportSentMessage : ListModeCommand() data object ReevalutePromotions : ListModeCommand() } + @Parcelize + data class ImportPasswordConfig( + val canImportFromGooglePasswordManager: Boolean, + ) : Parcelable + sealed class DuckAddressStatus { object NotADuckAddress : DuckAddressStatus() data class FetchingActivationStatus(val address: String) : DuckAddressStatus() 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 45a54b9a7c6e..7b5fbe61d7e2 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 @@ -25,11 +25,13 @@ import android.view.ViewGroup import android.widget.CompoundButton import androidx.activity.result.contract.ActivityResultContracts import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.os.BundleCompat import androidx.core.text.toSpanned import androidx.core.view.MenuProvider import androidx.core.view.children import androidx.core.view.updateLayoutParams import androidx.core.view.updateMargins +import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State import androidx.lifecycle.ViewModelProvider @@ -47,8 +49,10 @@ 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.importing.PasswordImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult.InProgress +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 @@ -56,6 +60,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementR import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementRecyclerAdapter.ContextMenuAction.Delete 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.ImportPasswordConfig 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.LaunchReportAutofillBreakageConfirmation @@ -68,6 +73,8 @@ import com.duckduckgo.autofill.impl.ui.credential.management.sorting.CredentialG import com.duckduckgo.autofill.impl.ui.credential.management.sorting.InitialExtractor import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionListBuilder import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionMatcher +import com.duckduckgo.autofill.impl.ui.credential.management.viewing.SelectImportPasswordMethodDialog.Companion.Result +import com.duckduckgo.autofill.impl.ui.credential.management.viewing.SelectImportPasswordMethodDialog.Companion.Result.UserChoseGcmImport import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.view.SearchBar @@ -141,7 +148,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 @@ -239,10 +247,36 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill } private fun configureImportPasswordsButton() { - binding.emptyStateLayout.importPasswordsButton.setOnClickListener { + binding.emptyStateLayout.importPasswordsFromGoogleButton.setOnClickListener { viewModel.onImportPasswords() - pixel.fire(AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON) } + + binding.emptyStateLayout.importPasswordsViaDesktopSyncButton.setOnClickListener { + launchImportPasswordsFromDesktopSyncScreen() + pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON) + } + + setFragmentResultListener(SelectImportPasswordMethodDialog.RESULT_KEY) { _, result -> + when (val importResult = BundleCompat.getParcelable(result, SelectImportPasswordMethodDialog.RESULT_KEY_DETAILS, Result::class.java)) { + is UserChoseGcmImport -> { + when (importResult.importResult) { + is Finished -> { + } + is InProgress -> { + } + } + } + else -> {} + } + } + } + + private fun userImportedViaCsv(numberImported: Int) { + Snackbar.make(binding.root, getString(R.string.autofillImportCsvPasswordsSuccessMessage, numberImported), Snackbar.LENGTH_LONG).show() + } + + private fun userImportViaGcm(numberImported: Int) { + Snackbar.make(binding.root, getString(R.string.autofillImportGooglePasswordsSuccessMessage, numberImported), Snackbar.LENGTH_LONG).show() } private fun configureToolbar() { @@ -256,7 +290,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() } @@ -266,7 +301,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 } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { @@ -286,9 +322,14 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill true } - R.id.importPasswords -> { + R.id.importGooglePasswords -> { viewModel.onImportPasswords() - pixel.fire(AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU) + true + } + + R.id.syncDesktopPasswords -> { + launchImportPasswordsFromDesktopSyncScreen() + pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU) true } @@ -380,7 +421,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill LaunchResetNeverSaveListConfirmation -> launchResetNeverSavedSitesConfirmation() is LaunchDeleteAllPasswordsConfirmation -> launchDeleteAllLoginsConfirmationDialog(command.numberToDelete) is PromptUserToAuthenticateMassDeletion -> promptUserToAuthenticateMassDeletion(command.authConfiguration) - is LaunchImportPasswords -> launchImportPasswordsScreen() + is LaunchImportPasswords -> launchImportPasswordsScreen(command.config) is LaunchReportAutofillBreakageConfirmation -> launchReportBreakageConfirmation(command.eTldPlusOne) is ShowUserReportSentMessage -> showUserReportSentMessage() is ReevalutePromotions -> configurePromotionsContainer() @@ -392,7 +433,19 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill Snackbar.make(binding.root, R.string.autofillManagementReportBreakageSuccessMessage, Snackbar.LENGTH_LONG).show() } - private fun launchImportPasswordsScreen() { + private fun launchImportPasswordsScreen(config: ImportPasswordConfig) { + context?.let { + if (!config.canImportFromGooglePasswordManager) { + // fallback to existing import screen + launchImportPasswordsFromDesktopSyncScreen() + } else { + val dialog = SelectImportPasswordMethodDialog.instance(config) + dialog.show(parentFragmentManager, "SelectImportPasswordMethodDialog") + } + } + } + + private fun launchImportPasswordsFromDesktopSyncScreen() { context?.let { globalActivityStarter.start(it, ImportPasswordActivityParams) } @@ -603,7 +656,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) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/SelectImportPasswordMethodDialog.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/SelectImportPasswordMethodDialog.kt new file mode 100644 index 000000000000..5a1d737f636b --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/SelectImportPasswordMethodDialog.kt @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2022 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.viewing + +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.IntentCompat +import androidx.core.os.BundleCompat +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.Lifecycle +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.api.AutofillScreens.ImportGooglePassword +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.ContentChooseImportPasswordMethodBinding +import com.duckduckgo.autofill.impl.deviceauth.AutofillAuthorizationGracePeriod +import com.duckduckgo.autofill.impl.importing.PasswordImporter +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult.Finished +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.AutofillSettingsViewModel.ImportPasswordConfig +import com.duckduckgo.autofill.impl.ui.credential.management.viewing.SelectImportPasswordMethodDialog.Companion.Result.UserChoseGcmImport +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.launch +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@InjectWith(FragmentScope::class) +class SelectImportPasswordMethodDialog : BottomSheetDialogFragment() { + + @Inject + lateinit var pixel: Pixel + + @Inject + lateinit var passwordImporter: PasswordImporter + + /** + * 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: ContentChooseImportPasswordMethodBinding? = null + + private val binding get() = _binding!! + + private val importGooglePasswordsFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult -> + Timber.i("cdr onActivityResult for Google Password Manager import flow. resultCode=${activityResult.resultCode}") + + if (activityResult.resultCode == Activity.RESULT_OK) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + activityResult.data?.let { data -> + val resultDetails = IntentCompat.getParcelableExtra( + data, + ImportGooglePasswordResult.RESULT_KEY_DETAILS, + ImportGooglePasswordResult::class.java, + ) + when (resultDetails) { + is ImportGooglePasswordResult.Success -> { observeImportJob(resultDetails.importJobId) } + ImportGooglePasswordResult.Error -> TODO() + is ImportGooglePasswordResult.UserCancelled -> TODO() + null -> TODO() + } + } + } + } + } + } + + private suspend fun observeImportJob(jobId: String) { + passwordImporter.getImportStatus(jobId).collect { + when (it) { + is ImportResult.InProgress -> { + // we can show the in-progress state, update duplicates etc... + Timber.d("cdr Import in progress: ${it.savedCredentialIds.size} of ${it.importListSize}") + } + + is Finished -> { + Timber.d( + "cdr Import finished: " + + "${it.savedCredentialIds.size} imported. " + + "${it.duplicatedPasswords.size} duplicates. " + + "Total=${it.importListSize}", + ) + processSuccessResult(it) + } + } + } + } + + private fun processSuccessResult(result: Finished) { + binding.postflow.errorNotImported.visibility = GONE + + with(binding.postflow.resultsImported) { + setSecondaryText(result.savedCredentialIds.size.toString()) + } + + with(binding.postflow.duplicatesNotImported) { + setSecondaryText(result.duplicatedPasswords.size.toString()) + visibility = if (result.duplicatedPasswords.isNotEmpty()) VISIBLE else GONE + } + + with(binding.postflow.primaryCtaButton) { + setOnClickListener { + setResult(UserChoseGcmImport(result)) + dismiss() + } + setText(R.string.importPasswordsProcessingResultDialogDoneButtonText) + } + + binding.prePostViewSwitcher.displayedChild = 1 + } + + private fun processErrorResult() { + binding.postflow.resultsImported.visibility = GONE + binding.postflow.duplicatesNotImported.visibility = GONE + binding.postflow.errorNotImported.visibility = VISIBLE + + with(binding.postflow.primaryCtaButton) { + setOnClickListener { + launchImportGcmFlow() + } + text = getString(R.string.importPasswordsProcessingResultDialogRetryButtonText) + } + + binding.prePostViewSwitcher.displayedChild = 1 + } + + 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 = ContentChooseImportPasswordMethodBinding.inflate(inflater, container, false) + configureViews(binding) + return binding.root + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + private fun configureViews(binding: ContentChooseImportPasswordMethodBinding) { + (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED + configureCloseButton(binding) + + val config = BundleCompat.getParcelable(requireArguments(), INPUT_KEY_CONFIG, ImportPasswordConfig::class.java)!! + + with(binding.preflow.importGcmButton) { + visibility = if (config.canImportFromGooglePasswordManager) VISIBLE else GONE + setOnClickListener { onImportGcmButtonClicked() } + } + } + + private fun onImportGcmButtonClicked() { + launchImportGcmFlow() + } + + private fun launchImportGcmFlow() { + authorizationGracePeriod.requestExtendedGracePeriod() + + val intent = globalActivityStarter.startIntent(requireContext(), ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen) + importGooglePasswordsFlowLauncher.launch(intent) + } + + private fun setResult(result: Result?) { + val resultBundle = Bundle().apply { + putParcelable(RESULT_KEY_DETAILS, result) + } + setFragmentResult(RESULT_KEY, resultBundle) + dismiss() + } + + override fun onCancel(dialog: DialogInterface) { + if (ignoreCancellationEvents) { + Timber.v("onCancel: Ignoring cancellation event") + return + } + setResult(Result.UserCancelled) + } + + private fun configureCloseButton(binding: ContentChooseImportPasswordMethodBinding) { + binding.closeButton.setOnClickListener { (dialog as BottomSheetDialog).animateClosed() } + } + + companion object { + + fun instance(configuration: ImportPasswordConfig): SelectImportPasswordMethodDialog { + val fragment = SelectImportPasswordMethodDialog() + fragment.arguments = Bundle().also { + it.putParcelable(INPUT_KEY_CONFIG, configuration) + } + return fragment + } + + const val RESULT_KEY = "SelectImportPasswordMethodDialogResult" + const val RESULT_KEY_DETAILS = "SelectImportPasswordMethodDialogResultDetails" + + private const val INPUT_KEY_CONFIG = "config" + + sealed interface Result : Parcelable { + + @Parcelize + data class UserChoseGcmImport(val importResult: ImportResult) : Result + + @Parcelize + data object UserCancelled : Result + + @Parcelize + data object ErrorDuringImport : Result + } + } +} 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..6f7bde8abdef --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/autofill_gpm_export_instruction.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + 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/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..d83cb014d0aa 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 @@ -72,7 +72,7 @@ android:text="@string/credentialManagementNoLoginsSavedSubtitle" /> + android:text="@string/autofillImportGooglePasswordEmptyStateButtonTitle" /> + + diff --git a/autofill/autofill-impl/src/main/res/layout/content_choose_import_password_method.xml b/autofill/autofill-impl/src/main/res/layout/content_choose_import_password_method.xml new file mode 100644 index 000000000000..3f43dec0cffd --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_choose_import_password_method.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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..5aaa5e81a434 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_post_flow.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + \ 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..d87170884123 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/content_import_google_password_pre_flow.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + \ 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 eff5d24dd1e2..b028fb254d17 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -20,4 +20,24 @@ Import Google Passwords %1$d passwords imported from Google + %1$d passwords imported from CSV + + Import Passwords from Google + Import your Google Passwords + + Sync Desktop Passwords + Sync Desktop Passwords + + Import your Google Passwords + Google may require you to login or ask for your password to confirm. + Open Google Passwords + Choose a CSV file + Import from Desktop Browser + + Import to DuckDuckGo + Got It + Retry + Password import failed + Duplicate Passwords Skipped + Passwords Imported \ No newline at end of file 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 a33a0ad89d54..7c448398ab33 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 @@ -141,7 +141,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { observePasswordInputUpdates(jobId) } is CsvPasswordImportResult.Error -> { - "Failed to import passwords due to an error".showSnackbar() + FAILED_IMPORT_GENERIC_ERROR.showSnackbar() } } } @@ -159,7 +159,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { observePasswordInputUpdates(resultDetails.importJobId) } Error -> { - "Failed to import passwords due to an error".showSnackbar() + FAILED_IMPORT_GENERIC_ERROR.showSnackbar() } is UserCancelled, null -> { } @@ -581,6 +581,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",