From f213b31fbc8c475f92b06a3216b008f7aea8b339 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Thu, 21 Nov 2024 14:59:38 +0000 Subject: [PATCH] Add pixels for importing google passwords metrics (#5284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/608920331025315/1208787586139232/f ### Description Adds metrics around the import password flow. To support this in a flexible way, it also allows for the url mappings to be defined remotely for which stage of the flow a user dropped out at if they didn't go all the way through; if we ever need to, can override the default url mappings. Logcat filter: `message~:"Pixel sent: autofill_import_google_passwords"` ### Steps to test this PR **Setup** - [x] Fresh install **Cancelling the user journey** - [x] Visit `Passwords` screen, and verify `autofill_import_google_passwords_import_button_shown` in logs for the import password button being shown - [x] Tap on `Import Passwords From Google` button; verify`autofill_import_google_passwords_import_button_tapped` in logs - [x] Verify `autofill_import_google_passwords_preimport_prompt_displayed` in logs - [x] Dismiss the import dialog; verify`autofill_import_google_passwords_result_user_cancelled` in logs - [x] Tap on `Import Passwords From Google` button again. - [x] This time, tap on the `Open Google Passwords` button; verify`autofill_import_google_passwords_preimport_prompt_confirmed` in logs - [x] Tap the ✖️ button to quit the import flow; verify`autofill_import_google_passwords_result_user_cancelled` in logs, with `stage=webflow-pre-login` - [x] Dismiss the dialog that is still showing, and verify there is not a further cancellation pixel in logs for when the dialog closes, as this is already covered by the ✖️ button cancellation - [x] Launch the import flow again. This time tap on the `Sign in` button. Then ✖️ to exit the flow; verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-authenticate` - [x] Launch the import flow again. This time, actually sign in, then ✖️ to exit the flow; verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-post-login-landing` - [x] Launch the import flow again, and you'll be on the screen with the export button. tap ✖️ to exit the flow; verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-export` - [x] Launch the import flow again. Tap the export button, and agree on the dialog. Then when prompted to authenticate, tap ✖️ to exit the flow; verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-authenticate` **Succeeding** - [x] Launch the import flow and fully complete it. Verify `autofill_import_google_passwords_result_success`, with the correct _bucket_ for `saved_credentials` and `skipped_credentials` based on bucket rules [defined here](https://app.asana.com/0/72649045549333/1207437778421216/f) - [x] Repeat that, and you should get duplicates this time. Verify again, ensuring that `skipped_credentials` bucket is accurate. **Overflow menu** - [x] Now that you have saved passwords, `Import Passwords From Google` will appear in the overflow menu. Tap on that. Verify `autofill_import_google_passwords_overflow_menu_tapped` and `autofill_import_google_passwords_preimport_prompt_displayed` in logs **Encrypted passphrase scenario** - [x] Apply [patch](https://app.asana.com/0/488551667048375/1208796814221367/f) - [x] Launch import flow and you'll see the error screen. Tap ✖️ to exit flow. Verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-passphrase-encryption` **CSV parsing scenario** - [x] Discard local changes - [x] Apply [patch](https://app.asana.com/0/488551667048375/1208796814221368/f) - [x] Launch import flow and carry on until you've exported. The patch will simulate an error. Verify `autofill_import_google_passwords_result_parsing` in logs. **URL mapping via remote config** - [x] Discard local changes - [x] Apply [patch](https://app.asana.com/0/488551667048375/1208799215631036/f) to override the URL mappings - [x] Fresh install. Launch password import flow and then ✖️ to exit. Verify in logs with filter `urlMappings` that the mappings from the patched jsonblob are used and override the local defaults. **Selectively disabling JS injection** - [x] Discard local changes - [x] Apply [patch](https://app.asana.com/0/488551667048375/1208799215631037/f) which simulates disabling remote config for JS injection specifically. - [x] Fresh install - [x] Launch password import flow; verify you don't see any UI hints on which button to press - [x] Complete the flow; verify it still works as expected --- .../store/AutofillEngagementBucketing.kt | 12 +- .../store/AutofillEngagementRepository.kt | 4 +- .../feature/AutofillImportPasswordSettings.kt | 45 +++++-- .../ImportGooglePasswordUrlToStageMapper.kt | 43 +++++++ .../ImportGooglePasswordsWebFlowFragment.kt | 10 +- .../ImportGooglePasswordsWebFlowViewModel.kt | 26 ++--- .../autofill/impl/pixel/AutofillPixelNames.kt | 9 ++ .../management/AutofillSettingsViewModel.kt | 4 +- .../ImportPasswordsPixelSender.kt | 110 ++++++++++++++++++ .../google/ImportFromGooglePasswordsDialog.kt | 27 +++-- ...mportFromGooglePasswordsDialogViewModel.kt | 15 ++- .../management/survey/AutofillSurvey.kt | 2 +- .../viewing/AutofillManagementListMode.kt | 12 +- .../DefaultAutofillEngagementBucketingTest.kt | 20 ++-- ...tofillImportPasswordConfigStoreImplTest.kt | 47 +++++++- ...tGooglePasswordUrlToStageMapperImplTest.kt | 69 +++++++++++ ...portGooglePasswordsWebFlowViewModelTest.kt | 20 ++-- ...tFromGooglePasswordsDialogViewModelTest.kt | 13 +-- .../survey/AutofillSurveyImplTest.kt | 2 +- 19 files changed, 397 insertions(+), 93 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapper.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/ImportPasswordsPixelSender.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapperImplTest.kt diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementBucketing.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementBucketing.kt index 2ebb2684256b..35f42d1c3cbd 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementBucketing.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementBucketing.kt @@ -26,7 +26,7 @@ import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject interface AutofillEngagementBucketing { - fun bucketNumberOfSavedPasswords(savedPasswords: Int): String + fun bucketNumberOfCredentials(numberOfCredentials: Int): String companion object { const val NONE = "none" @@ -40,12 +40,12 @@ interface AutofillEngagementBucketing { @ContributesBinding(AppScope::class) class DefaultAutofillEngagementBucketing @Inject constructor() : AutofillEngagementBucketing { - override fun bucketNumberOfSavedPasswords(savedPasswords: Int): String { + override fun bucketNumberOfCredentials(numberOfCredentials: Int): String { return when { - savedPasswords == 0 -> NONE - savedPasswords < 4 -> FEW - savedPasswords < 11 -> SOME - savedPasswords < 50 -> MANY + numberOfCredentials == 0 -> NONE + numberOfCredentials < 4 -> FEW + numberOfCredentials < 11 -> SOME + numberOfCredentials < 50 -> MANY else -> LOTS } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt index 6c29ca6e9772..a5f730441fbf 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/engagement/store/AutofillEngagementRepository.kt @@ -97,7 +97,7 @@ class DefaultAutofillEngagementRepository @Inject constructor( val numberStoredPasswords = getNumberStoredPasswords() val togglePixel = if (autofillStore.autofillEnabled) AUTOFILL_TOGGLED_ON_SEARCH else AUTOFILL_TOGGLED_OFF_SEARCH - val bucket = engagementBucketing.bucketNumberOfSavedPasswords(numberStoredPasswords) + val bucket = engagementBucketing.bucketNumberOfCredentials(numberStoredPasswords) pixel.fire(togglePixel, mapOf("count_bucket" to bucket), type = Daily()) } @@ -113,7 +113,7 @@ class DefaultAutofillEngagementRepository @Inject constructor( if (autofilled && searched) { pixel.fire(AUTOFILL_ENGAGEMENT_ACTIVE_USER, type = Daily()) - val bucket = engagementBucketing.bucketNumberOfSavedPasswords(numberStoredPasswords) + val bucket = engagementBucketing.bucketNumberOfCredentials(numberStoredPasswords) pixel.fire(AUTOFILL_ENGAGEMENT_STACKED_LOGINS, mapOf("count_bucket" to bucket), type = Daily()) } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt index 48039ef97554..60956ec71787 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt @@ -33,7 +33,14 @@ interface AutofillImportPasswordConfigStore { data class AutofillImportPasswordSettings( val canImportFromGooglePasswords: Boolean, val launchUrlGooglePasswords: String, + val canInjectJavascript: Boolean, val javascriptConfigGooglePasswords: String, + val urlMappings: List, +) + +data class UrlMapping( + val key: String, + val url: String, ) @ContributesBinding(AppScope::class) @@ -43,8 +50,8 @@ class AutofillImportPasswordConfigStoreImpl @Inject constructor( private val moshi: Moshi, ) : AutofillImportPasswordConfigStore { - private val jsonAdapter: JsonAdapter by lazy { - moshi.adapter(CanImportFromGooglePasswordManagerConfig::class.java) + private val jsonAdapter: JsonAdapter by lazy { + moshi.adapter(ImportConfigJson::class.java) } override suspend fun getConfig(): AutofillImportPasswordSettings { @@ -54,24 +61,48 @@ class AutofillImportPasswordConfigStoreImpl @Inject constructor( jsonAdapter.fromJson(it) }.getOrNull() } - val launchUrl = config?.launchUrl ?: LAUNCH_URL_DEFAULT - val javascriptConfig = config?.javascriptConfig?.toString() ?: JAVASCRIPT_CONFIG_DEFAULT AutofillImportPasswordSettings( canImportFromGooglePasswords = autofillFeature.canImportFromGooglePasswordManager().isEnabled(), - launchUrlGooglePasswords = launchUrl, - javascriptConfigGooglePasswords = javascriptConfig, + launchUrlGooglePasswords = config?.launchUrl ?: LAUNCH_URL_DEFAULT, + canInjectJavascript = config?.canInjectJavascript ?: CAN_INJECT_JAVASCRIPT_DEFAULT, + javascriptConfigGooglePasswords = config?.javascriptConfig?.toString() ?: JAVASCRIPT_CONFIG_DEFAULT, + urlMappings = config?.urlMappings.convertFromJsonModel(), ) } } companion object { internal const val JAVASCRIPT_CONFIG_DEFAULT = "\"{}\"" + internal const val CAN_INJECT_JAVASCRIPT_DEFAULT = true + internal const val LAUNCH_URL_DEFAULT = "https://passwords.google.com/options?ep=1" + + // order is important; first match wins so keep the most specific to start of the list + internal val URL_MAPPINGS_DEFAULT = listOf( + UrlMapping(key = "webflow-passphrase-encryption", url = "https://passwords.google.com/error/sync-passphrase"), + UrlMapping(key = "webflow-pre-login", url = "https://passwords.google.com/intro"), + UrlMapping(key = "webflow-export", url = "https://passwords.google.com/options?ep=1"), + UrlMapping(key = "webflow-authenticate", url = "https://accounts.google.com/"), + UrlMapping(key = "webflow-post-login-landing", url = "https://passwords.google.com"), + ) } - private data class CanImportFromGooglePasswordManagerConfig( + private data class ImportConfigJson( val launchUrl: String? = null, + val canInjectJavascript: Boolean = CAN_INJECT_JAVASCRIPT_DEFAULT, val javascriptConfig: JSONObject? = null, + val urlMappings: List? = null, ) + + private data class UrlMappingJson( + val key: String, + val url: String, + ) + + private fun List?.convertFromJsonModel(): List { + return this?.let { jsonList -> + jsonList.map { UrlMapping(key = it.key, url = it.url) } + } ?: URL_MAPPINGS_DEFAULT + } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapper.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapper.kt new file mode 100644 index 000000000000..99b0a5f1d625 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapper.kt @@ -0,0 +1,43 @@ +/* + * 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.importing.gpm.webflow + +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import timber.log.Timber + +interface ImportGooglePasswordUrlToStageMapper { + suspend fun getStage(url: String?): String +} + +@ContributesBinding(FragmentScope::class) +class ImportGooglePasswordUrlToStageMapperImpl @Inject constructor( + private val importPasswordConfigStore: AutofillImportPasswordConfigStore, +) : ImportGooglePasswordUrlToStageMapper { + + override suspend fun getStage(url: String?): String { + val config = importPasswordConfigStore.getConfig() + val stage = config.urlMappings.firstOrNull { url?.startsWith(it.url) == true }?.key ?: UNKNOWN + return stage.also { Timber.d("Mapped as stage $it for $url") } + } + + companion object { + const val UNKNOWN = "webflow-unknown" + } +} 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 1e77d0a68bbf..7ccae5a67eb6 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 @@ -43,6 +43,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.databinding.FragmentImportGooglePasswordsWebflowBinding import com.duckduckgo.autofill.impl.importing.blob.GooglePasswordBlobConsumer +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore 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.UserCannotImportReason @@ -114,6 +115,9 @@ class ImportGooglePasswordsWebFlowFragment : @Inject lateinit var browserAutofillConfigurator: BrowserAutofill.Configurator + @Inject + lateinit var importPasswordConfig: AutofillImportPasswordConfigStore + private var binding: FragmentImportGooglePasswordsWebflowBinding? = null private val viewModel by lazy { @@ -283,8 +287,10 @@ class ImportGooglePasswordsWebFlowFragment : @SuppressLint("RequiresFeature") private suspend fun configurePasswordImportJavascript(webView: WebView) { - val script = passwordImporterScriptLoader.getScript() - WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) + if (importPasswordConfig.getConfig().canInjectJavascript) { + val script = passwordImporterScriptLoader.getScript() + WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*")) + } } private fun getToolbar() = (activity as ImportGooglePasswordsWebFlowActivity).binding.includeToolbar.toolbar 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 6e74fc3dd801..88074d142cad 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 @@ -24,11 +24,11 @@ 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.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.di.scopes.FragmentScope import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -36,12 +36,13 @@ import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import timber.log.Timber -@ContributesViewModel(ActivityScope::class) +@ContributesViewModel(FragmentScope::class) class ImportGooglePasswordsWebFlowViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val credentialImporter: CredentialImporter, private val csvCredentialConverter: CsvCredentialConverter, private val autofillImportConfigStore: AutofillImportPasswordConfigStore, + private val urlToStageMapper: ImportGooglePasswordUrlToStageMapper, ) : ViewModel() { private val _viewState = MutableStateFlow(Initializing) @@ -71,11 +72,7 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( } fun onCloseButtonPressed(url: String?) { - if (url?.startsWith(ENCRYPTED_PASSPHRASE_ERROR_URL) == true) { - _viewState.value = ViewState.UserFinishedCannotImport(EncryptedPassphrase) - } else { - terminateFlowAsCancellation(url ?: "unknown") - } + terminateFlowAsCancellation(url ?: "unknown") } fun onBackButtonPressed( @@ -91,8 +88,10 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( _viewState.value = ViewState.NavigatingBack } - private fun terminateFlowAsCancellation(stage: String) { - _viewState.value = ViewState.UserCancelledImportFlow(stage) + private fun terminateFlowAsCancellation(url: String) { + viewModelScope.launch { + _viewState.value = UserCancelledImportFlow(urlToStageMapper.getStage(url)) + } } fun firstPageLoading() { @@ -112,16 +111,9 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( sealed interface UserCannotImportReason : Parcelable { @Parcelize data object ErrorParsingCsv : UserCannotImportReason - - @Parcelize - data object EncryptedPassphrase : UserCannotImportReason } sealed interface BackButtonAction { data object NavigateBack : BackButtonAction } - - companion object { - const val ENCRYPTED_PASSPHRASE_ERROR_URL = "https://passwords.google.com/error/sync-passphrase" - } } 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 bdc925101e0a..fbe453193599 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 @@ -142,6 +142,15 @@ 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_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_TAPPED("autofill_import_google_passwords_import_button_tapped"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN("autofill_import_google_passwords_import_button_shown"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_OVERFLOW_MENU("autofill_import_google_passwords_overflow_menu_tapped"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_DISPLAYED("autofill_import_google_passwords_preimport_prompt_displayed"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_CONFIRMED("autofill_import_google_passwords_preimport_prompt_confirmed"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_ERROR_PARSING("autofill_import_google_passwords_result_parsing"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_USER_CANCELLED("autofill_import_google_passwords_result_user_cancelled"), + AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_SUCCESS("autofill_import_google_passwords_result_success"), + 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"), 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 1878ed2f155d..4f7466709cd6 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 @@ -44,6 +44,7 @@ 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 import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_ENABLED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_MANAGEMENT_SCREEN_OPENED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_MANUALLY_SAVE_CREDENTIAL import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE_FOR_THIS_SITE_CONFIRMATION_PROMPT_CONFIRMED @@ -796,8 +797,7 @@ class AutofillSettingsViewModel @Inject constructor( fun recordImportGooglePasswordButtonShown() { if (!importGooglePasswordButtonShownPixelSent) { importGooglePasswordButtonShownPixelSent = true - - // pixel to show import button would fire here + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN) } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/ImportPasswordsPixelSender.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/ImportPasswordsPixelSender.kt new file mode 100644 index 000000000000..c7f951697e70 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/importpassword/ImportPasswordsPixelSender.kt @@ -0,0 +1,110 @@ +/* + * 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 + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.autofill.impl.engagement.store.AutofillEngagementBucketing +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.ErrorParsingCsv +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_TAPPED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_OVERFLOW_MENU +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_CONFIRMED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_DISPLAYED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_ERROR_PARSING +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_USER_CANCELLED +import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_SUCCESS +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.di.scopes.FragmentScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface ImportPasswordsPixelSender { + fun onImportPasswordsDialogDisplayed() + fun onImportPasswordsDialogImportButtonClicked() + fun onUserCancelledImportPasswordsDialog() + fun onUserCancelledImportWebFlow(stage: String) + fun onImportSuccessful(savedCredentials: Int, numberSkipped: Int) + fun onImportFailed(reason: UserCannotImportReason) + fun onImportPasswordsButtonTapped() + fun onImportPasswordsOverflowMenuTapped() + fun onImportPasswordsViaDesktopSyncButtonTapped() + fun onImportPasswordsViaDesktopSyncOverflowMenuTapped() +} + +@ContributesBinding(FragmentScope::class) +class ImportPasswordsPixelSenderImpl @Inject constructor( + private val pixel: Pixel, + private val engagementBucketing: AutofillEngagementBucketing, +) : ImportPasswordsPixelSender { + + override fun onImportPasswordsDialogDisplayed() { + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_DISPLAYED) + } + + override fun onImportPasswordsDialogImportButtonClicked() { + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_CONFIRMED) + } + + override fun onUserCancelledImportPasswordsDialog() { + val params = mapOf(CANCELLATION_STAGE_KEY to PRE_IMPORT_DIALOG_STAGE) + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_USER_CANCELLED, params) + } + + override fun onUserCancelledImportWebFlow(stage: String) { + val params = mapOf(CANCELLATION_STAGE_KEY to stage) + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_USER_CANCELLED, params) + } + + override fun onImportSuccessful(savedCredentials: Int, numberSkipped: Int) { + val savedCredentialsBucketed = engagementBucketing.bucketNumberOfCredentials(savedCredentials) + val skippedCredentialsBucketed = engagementBucketing.bucketNumberOfCredentials(numberSkipped) + val params = mapOf( + "saved_credentials" to savedCredentialsBucketed, + "skipped_credentials" to skippedCredentialsBucketed, + ) + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_SUCCESS, params) + } + + override fun onImportFailed(reason: UserCannotImportReason) { + val pixelName = when (reason) { + ErrorParsingCsv -> AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_ERROR_PARSING + } + pixel.fire(pixelName) + } + + override fun onImportPasswordsButtonTapped() { + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_TAPPED) + } + + override fun onImportPasswordsOverflowMenuTapped() { + pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_OVERFLOW_MENU) + } + + override fun onImportPasswordsViaDesktopSyncButtonTapped() { + pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON) + } + + override fun onImportPasswordsViaDesktopSyncOverflowMenuTapped() { + pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU) + } + + companion object { + private const val CANCELLATION_STAGE_KEY = "stage" + private const val PRE_IMPORT_DIALOG_STAGE = "pre-import-dialog" + } +} 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 index 3a533ea8fa5c..7b8605dd489f 100644 --- 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 @@ -32,10 +32,8 @@ 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 @@ -43,6 +41,7 @@ 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.ImportPasswordsPixelSender 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 @@ -66,7 +65,7 @@ import timber.log.Timber class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { @Inject - lateinit var pixel: Pixel + lateinit var importPasswordsPixelSender: ImportPasswordsPixelSender /** * To capture all the ways the BottomSheet can be dismissed, we might end up with onCancel being called when we don't want it @@ -97,10 +96,8 @@ class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { 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) - } + activityResult.data?.let { data -> + processImportFlowResult(data) } } } @@ -111,7 +108,7 @@ class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { when (it) { is ImportGooglePasswordResult.Success -> viewModel.onImportFlowFinishedSuccessfully() is ImportGooglePasswordResult.Error -> viewModel.onImportFlowFinishedWithError(it.reason) - is ImportGooglePasswordResult.UserCancelled -> viewModel.onImportFlowCancelledByUser() + is ImportGooglePasswordResult.UserCancelled -> viewModel.onImportFlowCancelledByUser(it.stage) else -> {} } } @@ -190,6 +187,8 @@ class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View { + importPasswordsPixelSender.onImportPasswordsDialogDisplayed() + _binding = ContentImportFromGooglePasswordDialogBinding.inflate(inflater, container, false) configureViews(binding) observeViewModel() @@ -233,10 +232,6 @@ class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { } private fun onImportGcmButtonClicked() { - launchImportGcmFlow() - } - - private fun launchImportGcmFlow() { authorizationGracePeriod.requestExtendedGracePeriod() val intent = globalActivityStarter.startIntent( @@ -244,6 +239,11 @@ class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen, ) importGooglePasswordsFlowLauncher.launch(intent) + + importPasswordsPixelSender.onImportPasswordsDialogImportButtonClicked() + + // we don't want the eventual dismissal of this dialog to count as a cancellation + ignoreCancellationEvents = true } override fun onCancel(dialog: DialogInterface) { @@ -251,6 +251,9 @@ class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { Timber.v("onCancel: Ignoring cancellation event") return } + + importPasswordsPixelSender.onUserCancelledImportPasswordsDialog() + dismiss() } 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 index 1ab1a1860cf1..c1a35353f0ba 100644 --- 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 @@ -22,6 +22,7 @@ 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.ImportPasswordsPixelSender 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 @@ -36,6 +37,7 @@ import timber.log.Timber class ImportFromGooglePasswordsDialogViewModel @Inject constructor( private val credentialImporter: CredentialImporter, private val dispatchers: DispatcherProvider, + private val importPasswordsPixelSender: ImportPasswordsPixelSender, ) : ViewModel() { fun onImportFlowFinishedSuccessfully() { @@ -54,6 +56,7 @@ class ImportFromGooglePasswordsDialogViewModel @Inject constructor( is ImportResult.Finished -> { Timber.d("Import finished: ${it.savedCredentials} imported. ${it.numberSkipped} skipped.") + fireImportSuccessPixel(savedCredentials = it.savedCredentials, numberSkipped = it.numberSkipped) _viewState.value = ViewState(viewMode = ViewMode.ImportSuccess(it)) } } @@ -61,10 +64,20 @@ class ImportFromGooglePasswordsDialogViewModel @Inject constructor( } fun onImportFlowFinishedWithError(reason: UserCannotImportReason) { + fireImportFailedPixel(reason) _viewState.value = ViewState(viewMode = ViewMode.ImportError) } - fun onImportFlowCancelledByUser() { + fun onImportFlowCancelledByUser(stage: String) { + importPasswordsPixelSender.onUserCancelledImportWebFlow(stage) + } + + private fun fireImportSuccessPixel(savedCredentials: Int, numberSkipped: Int) { + importPasswordsPixelSender.onImportSuccessful(savedCredentials = savedCredentials, numberSkipped = numberSkipped) + } + + private fun fireImportFailedPixel(reason: UserCannotImportReason) { + importPasswordsPixelSender.onImportFailed(reason) } private val _viewState = MutableStateFlow(ViewState()) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt index 120d8acbd9a4..8c0b38d0482a 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurvey.kt @@ -89,7 +89,7 @@ class AutofillSurveyImpl @Inject constructor( .appendQueryParameter(SurveyParams.MODEL, appBuildConfig.model) .appendQueryParameter(SurveyParams.SOURCE, IN_APP) .appendQueryParameter(SurveyParams.LAST_ACTIVE_DATE, appDaysUsedRepository.getLastActiveDay()) - .appendQueryParameter(SurveyParams.NUMBER_PASSWORDS, passwordBucketing.bucketNumberOfSavedPasswords(passwordsSaved)) + .appendQueryParameter(SurveyParams.NUMBER_PASSWORDS, passwordBucketing.bucketNumberOfCredentials(passwordsSaved)) urlBuilder.build().toString() } 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 8f705057012f..3f4469ae074b 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 @@ -38,7 +38,6 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.RecyclerView 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.AutofillSettingsLaunchSource import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin @@ -47,8 +46,6 @@ 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_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 @@ -65,6 +62,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsVie 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.ImportPasswordsPixelSender 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 @@ -129,7 +127,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill lateinit var screenPromotionPlugins: PluginPoint @Inject - lateinit var pixel: Pixel + lateinit var importPasswordsPixelSender: ImportPasswordsPixelSender val viewModel by lazy { ViewModelProvider(requireActivity(), viewModelFactory)[AutofillSettingsViewModel::class.java] @@ -246,11 +244,12 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill private fun configureImportPasswordsButton() { binding.emptyStateLayout.importPasswordsFromGoogleButton.setOnClickListener { viewModel.onImportPasswordsFromGooglePasswordManager() + importPasswordsPixelSender.onImportPasswordsButtonTapped() } binding.emptyStateLayout.importPasswordsViaDesktopSyncButton.setOnClickListener { launchImportPasswordsFromDesktopSyncScreen() - pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON) + importPasswordsPixelSender.onImportPasswordsViaDesktopSyncButtonTapped() } } @@ -299,12 +298,13 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill R.id.importGooglePasswords -> { viewModel.onImportPasswordsFromGooglePasswordManager() + importPasswordsPixelSender.onImportPasswordsOverflowMenuTapped() true } R.id.syncDesktopPasswords -> { launchImportPasswordsFromDesktopSyncScreen() - pixel.fire(AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU) + importPasswordsPixelSender.onImportPasswordsViaDesktopSyncOverflowMenuTapped() true } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/store/DefaultAutofillEngagementBucketingTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/store/DefaultAutofillEngagementBucketingTest.kt index 5db749d3cae7..d465c6d5d0f8 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/store/DefaultAutofillEngagementBucketingTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/engagement/store/DefaultAutofillEngagementBucketingTest.kt @@ -13,31 +13,31 @@ class DefaultAutofillEngagementBucketingTest { @Test fun whenZeroSavedPasswordsThenBucketIsNone() { - assertEquals(NONE, testee.bucketNumberOfSavedPasswords(0)) + assertEquals(NONE, testee.bucketNumberOfCredentials(0)) } @Test fun whenBetweenOneAndThreeSavedPasswordThenBucketIsFew() { - assertEquals(FEW, testee.bucketNumberOfSavedPasswords(1)) - assertEquals(FEW, testee.bucketNumberOfSavedPasswords(2)) - assertEquals(FEW, testee.bucketNumberOfSavedPasswords(3)) + assertEquals(FEW, testee.bucketNumberOfCredentials(1)) + assertEquals(FEW, testee.bucketNumberOfCredentials(2)) + assertEquals(FEW, testee.bucketNumberOfCredentials(3)) } @Test fun whenBetweenFourAndTenSavedPasswordThenBucketIsSome() { - assertEquals(SOME, testee.bucketNumberOfSavedPasswords(4)) - assertEquals(SOME, testee.bucketNumberOfSavedPasswords(10)) + assertEquals(SOME, testee.bucketNumberOfCredentials(4)) + assertEquals(SOME, testee.bucketNumberOfCredentials(10)) } @Test fun whenBetweenElevenAndFortyNineSavedPasswordThenBucketIsMany() { - assertEquals(MANY, testee.bucketNumberOfSavedPasswords(11)) - assertEquals(MANY, testee.bucketNumberOfSavedPasswords(49)) + assertEquals(MANY, testee.bucketNumberOfCredentials(11)) + assertEquals(MANY, testee.bucketNumberOfCredentials(49)) } @Test fun whenFiftyOrOverThenBucketIsMany() { - assertEquals(LOTS, testee.bucketNumberOfSavedPasswords(50)) - assertEquals(LOTS, testee.bucketNumberOfSavedPasswords(Int.MAX_VALUE)) + assertEquals(LOTS, testee.bucketNumberOfCredentials(50)) + assertEquals(LOTS, testee.bucketNumberOfCredentials(Int.MAX_VALUE)) } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt index 5264b3e54335..c387f9501cea 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt @@ -47,30 +47,63 @@ class AutofillImportPasswordConfigStoreImplTest { @Test fun whenLaunchUrlNotSpecifiedInConfigThenDefaultUsed() = runTest { - configureFeature(config = Config()) + configureFeature(config = Config(urlMappings = listOf(UrlMapping("key", "https://example.com")))) assertEquals(LAUNCH_URL_DEFAULT, testee.getConfig().launchUrlGooglePasswords) } @Test fun whenLaunchUrlSpecifiedInConfigThenOverridesDefault() = runTest { - configureFeature(config = Config(launchUrl = "https://example.com")) + configureFeature(config = Config(launchUrl = "https://example.com", urlMappings = listOf(UrlMapping("key", "https://example.com")))) assertEquals("https://example.com", testee.getConfig().launchUrlGooglePasswords) } @Test fun whenJavascriptConfigNotSpecifiedInConfigThenDefaultUsed() = runTest { - configureFeature(config = Config()) + configureFeature(config = Config(urlMappings = listOf(UrlMapping("key", "https://example.com")))) assertEquals(JAVASCRIPT_CONFIG_DEFAULT, testee.getConfig().javascriptConfigGooglePasswords) } @Test fun whenJavascriptConfigSpecifiedInConfigThenOverridesDefault() = runTest { - configureFeature(config = Config(javascriptConfig = JavaScriptConfig(key = "value", domains = listOf("foo, bar")))) + configureFeature( + config = Config( + javascriptConfig = JavaScriptConfig(key = "value", domains = listOf("foo, bar")), + urlMappings = listOf(UrlMapping("key", "https://example.com")), + ), + ) assertEquals("""{"domains":["foo, bar"],"key":"value"}""", testee.getConfig().javascriptConfigGooglePasswords) } + @Test + fun whenUrlMappingsSpecifiedInConfigOverridesDefault() = runTest { + configureFeature(config = Config(urlMappings = listOf(UrlMapping("key", "https://example.com")))) + testee.getConfig().urlMappings.apply { + assertEquals(1, size) + assertEquals("key", get(0).key) + assertEquals("https://example.com", get(0).url) + } + } + + @Test + fun whenUrlMappingsNotSpecifiedInConfigThenDefaultsUsed() = runTest { + configureFeature(config = Config(urlMappings = null)) + assertEquals(5, testee.getConfig().urlMappings.size) + } + + @Test + fun whenUrlMappingsNotSpecifiedInConfigThenCorrectOrderOfDefaultsReturned() = runTest { + configureFeature(config = Config(urlMappings = null)) + testee.getConfig().urlMappings.apply { + assertEquals("webflow-passphrase-encryption", get(0).key) + assertEquals("webflow-pre-login", get(1).key) + assertEquals("webflow-export", get(2).key) + assertEquals("webflow-authenticate", get(3).key) + assertEquals("webflow-post-login-landing", get(4).key) + } + } + @SuppressLint("DenyListedApi") - private fun configureFeature(enabled: Boolean = true, config: Config = Config()) { + private fun configureFeature(enabled: Boolean = true, config: Config = Config(urlMappings = listOf(UrlMapping("key", "https://example.com")))) { autofillFeature.canImportFromGooglePasswordManager().setRawStoredState( State( remoteEnableState = enabled, @@ -78,11 +111,13 @@ class AutofillImportPasswordConfigStoreImplTest { ), ) } - private data class Config( val launchUrl: String? = null, + val canInjectJavascript: Boolean = true, val javascriptConfig: JavaScriptConfig? = null, + val urlMappings: List?, ) + private data class JavaScriptConfig( val key: String, val domains: List, diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapperImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapperImplTest.kt new file mode 100644 index 000000000000..5b2fafd74482 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordUrlToStageMapperImplTest.kt @@ -0,0 +1,69 @@ +package com.duckduckgo.autofill.impl.importing.gpm.webflow + +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordSettings +import com.duckduckgo.autofill.impl.importing.gpm.feature.UrlMapping +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordUrlToStageMapperImpl.Companion.UNKNOWN +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ImportGooglePasswordUrlToStageMapperImplTest { + + private val importPasswordConfigStore: AutofillImportPasswordConfigStore = mock() + + private val testee = ImportGooglePasswordUrlToStageMapperImpl(importPasswordConfigStore = importPasswordConfigStore) + + @Before + fun setup() = runTest { + whenever(importPasswordConfigStore.getConfig()).thenReturn(config()) + } + + @Test + fun whenUrlIsEmptyStringThenStageIsUnknown() = runTest { + assertEquals(UNKNOWN, testee.getStage("")) + } + + @Test + fun whenUrlIsNullThenStageIsUnknown() = runTest { + assertEquals(UNKNOWN, testee.getStage(null)) + } + + @Test + fun whenUrlStartsWithKnownMappingThenValueReturned() = runTest { + listOf(UrlMapping("key", "https://example.com")).configureMappings() + assertEquals("key", testee.getStage("https://example.com")) + } + + @Test + fun whenUrlMatchesMultipleThenFirstValueReturned() = runTest { + listOf( + UrlMapping("key1", "https://example.com"), + UrlMapping("key2", "https://example.com"), + ).configureMappings() + assertEquals("key1", testee.getStage("https://example.com")) + } + + @Test + fun whenUrlHasDifferentPrefixThenNotAMatch() = runTest { + listOf(UrlMapping("key", "https://example.com")).configureMappings() + assertEquals(UNKNOWN, testee.getStage("example.com")) + } + + private suspend fun List.configureMappings() { + whenever(importPasswordConfigStore.getConfig()).thenReturn(config().copy(urlMappings = this)) + } + + private fun config(): AutofillImportPasswordSettings { + return AutofillImportPasswordSettings( + launchUrlGooglePasswords = "https://example.com", + canImportFromGooglePasswords = true, + canInjectJavascript = true, + javascriptConfigGooglePasswords = "{}", + urlMappings = emptyList(), + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt index cb9db23d1a7a..a92bba2aa106 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt @@ -9,7 +9,6 @@ import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredenti import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult.Success import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordSettings -import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Companion.ENCRYPTED_PASSPHRASE_ERROR_URL 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 @@ -34,12 +33,14 @@ class ImportGooglePasswordsWebFlowViewModelTest { private val credentialImporter: CredentialImporter = mock() private val csvCredentialConverter: CsvCredentialConverter = mock() private val autofillImportConfigStore: AutofillImportPasswordConfigStore = mock() + private val urlToStageMapper: ImportGooglePasswordUrlToStageMapper = mock() private val testee = ImportGooglePasswordsWebFlowViewModel( dispatchers = coroutineTestRule.testDispatcherProvider, credentialImporter = credentialImporter, csvCredentialConverter = csvCredentialConverter, autofillImportConfigStore = autofillImportConfigStore, + urlToStageMapper = urlToStageMapper, ) @Test @@ -77,6 +78,7 @@ class ImportGooglePasswordsWebFlowViewModelTest { @Test fun whenBackButtonPressedAndCannotGoBackThenUserCancelledImportFlowState() = runTest { + whenever(urlToStageMapper.getStage(any())).thenReturn("stage") testee.onBackButtonPressed(url = "https://example.com", canGoBack = false) testee.viewState.test { awaitItem() as UserCancelledImportFlow @@ -92,18 +94,12 @@ class ImportGooglePasswordsWebFlowViewModelTest { } @Test - fun whenCloseButtonPressedAndNotEncryptionErrorPageThenUserCancelledImportFlowState() = runTest { + fun whenCloseButtonPressedThenUserCancelledImportFlowState() = runTest { + val expectedStage = "stage" + whenever(urlToStageMapper.getStage(any())).thenReturn(expectedStage) testee.onCloseButtonPressed("https://example.com") testee.viewState.test { - awaitItem() as UserCancelledImportFlow - } - } - - @Test - fun whenCloseButtonPressedOnEncryptionErrorPageThenUserCancelledImportFlowState() = runTest { - testee.onCloseButtonPressed(ENCRYPTED_PASSPHRASE_ERROR_URL) - testee.viewState.test { - awaitItem() as UserFinishedCannotImport + assertEquals(expectedStage, (awaitItem() as UserCancelledImportFlow).stage) } } @@ -117,6 +113,8 @@ class ImportGooglePasswordsWebFlowViewModelTest { canImportFromGooglePasswords = canImportFromGooglePasswords, launchUrlGooglePasswords = launchUrlGooglePasswords, javascriptConfigGooglePasswords = javascriptConfigGooglePasswords, + canInjectJavascript = true, + urlMappings = emptyList(), ), ) } 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 index 19b4db995c84..87bf32ab469e 100644 --- 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 @@ -5,8 +5,8 @@ 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.ImportPasswordsPixelSender 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 @@ -29,10 +29,13 @@ class ImportFromGooglePasswordsDialogViewModelTest { @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule(StandardTestDispatcher()) + private val importPasswordsPixelSender: ImportPasswordsPixelSender = mock() + private val credentialImporter: CredentialImporter = mock() private val testee = ImportFromGooglePasswordsDialogViewModel( credentialImporter = credentialImporter, dispatchers = coroutineTestRule.testDispatcherProvider, + importPasswordsPixelSender = importPasswordsPixelSender, ) @Before @@ -48,14 +51,6 @@ class ImportFromGooglePasswordsDialogViewModelTest { } } - @Test - fun whenEncryptionErrorOnImportThenViewModeUpdatedToError() = runTest { - testee.onImportFlowFinishedWithError(EncryptedPassphrase) - testee.viewState.test { - assertTrue(awaitItem().viewMode is ViewMode.ImportError) - } - } - @Test fun whenSuccessfulImportThenViewModeUpdatedToInProgress() = runTest { configureImportInProgress() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt index d60cee2bb47a..2e2ddef2b65e 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/survey/AutofillSurveyImplTest.kt @@ -93,7 +93,7 @@ class AutofillSurveyImplTest { @Test fun whenSurveyLaunchedThenSavedPasswordQueryParamAdded() = runTest { - whenever(passwordBucketing.bucketNumberOfSavedPasswords(any())).thenReturn("fromBucketing") + whenever(passwordBucketing.bucketNumberOfCredentials(any())).thenReturn("fromBucketing") val survey = getAvailableSurvey() val savedPasswordsBucket = survey.url.toUri().getQueryParameter("saved_passwords") assertEquals("fromBucketing", savedPasswordsBucket)