From 28f11e5c26c186f1f2f5a19e2b71f171ebfab503 Mon Sep 17 00:00:00 2001 From: catalinradoiu Date: Mon, 31 Aug 2026 18:34:44 +0300 Subject: [PATCH 1/6] Move Google passwords import pixels to the import web flow Result pixels (user cancelled, CSV parsing error, WebView crash) now fire from ImportGooglePasswordsWebFlowViewModel, which receives the launch source via assisted injection. The import success pixel fires from an app-scoped observer of the credential importer's status flow, so consumers of the web flow no longer need import pixel logic of their own. Co-Authored-By: Claude Fable 5 --- .../pixels/definitions/autofill.json5 | 162 ++++++++++++++++++ .../impl/importing/CredentialImporter.kt | 9 +- .../ImportPasswordsResultPixelObserver.kt | 68 ++++++++ .../ImportGooglePasswordsWebFlowActivity.kt | 8 +- .../ImportGooglePasswordsWebFlowFragment.kt | 22 ++- .../ImportGooglePasswordsWebFlowViewModel.kt | 38 +++- .../ImportPasswordsPixelSender.kt | 4 +- .../google/ImportFromGooglePasswordsDialog.kt | 15 +- ...mportFromGooglePasswordsDialogViewModel.kt | 27 +-- .../importing/CredentialImporterImplTest.kt | 4 +- .../ImportPasswordsResultPixelObserverTest.kt | 59 +++++++ ...portGooglePasswordsWebFlowViewModelTest.kt | 54 ++++++ ...tFromGooglePasswordsDialogViewModelTest.kt | 15 +- .../AutofillInternalSettingsActivity.kt | 1 + 14 files changed, 430 insertions(+), 56 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserver.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserverTest.kt diff --git a/PixelDefinitions/pixels/definitions/autofill.json5 b/PixelDefinitions/pixels/definitions/autofill.json5 index 6b4943d6f23c..584f73916bc9 100644 --- a/PixelDefinitions/pixels/definitions/autofill.json5 +++ b/PixelDefinitions/pixels/definitions/autofill.json5 @@ -515,5 +515,167 @@ "triggers": ["exception"], "suffixes": ["form_factor"], "parameters": ["appVersion", "error"] + }, + "autofill_import_google_passwords_preimport_prompt_displayed": { + "description": "The prompt explaining the Google Password Manager import was displayed to the user.", + "owners": ["catalinradoiu"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + { + "key": "source", + "type": "string", + "description": "Where the import was launched from.", + "enum": [ + "password_management_promo", + "password_management_empty_state", + "password_management_overflow", + "autofill_settings_button", + "in_browser_promo", + "settings", + "onboarding", + "unknown" + ] + } + ] + }, + "autofill_import_google_passwords_preimport_prompt_confirmed": { + "description": "The user accepted the pre-import prompt, starting the Google Password Manager import web flow.", + "owners": ["catalinradoiu"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + { + "key": "source", + "type": "string", + "description": "Where the import was launched from.", + "enum": [ + "password_management_promo", + "password_management_empty_state", + "password_management_overflow", + "autofill_settings_button", + "in_browser_promo", + "settings", + "onboarding", + "unknown" + ] + } + ] + }, + "autofill_import_google_passwords_result_user_cancelled": { + "description": "The user backed out of the Google Password Manager import. `stage` says where they were when they left: the pre-import prompt, or a stage of the web flow itself.", + "owners": ["catalinradoiu"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + { + "key": "stage", + "type": "string", + "description": "Where the user left the import. `pre-import-dialog` for the prompt before the web flow; otherwise the web flow stage, mapped from the current URL against the URL mappings in the privacy config, or `webflow-unknown` when no mapping matches.", + "examples": ["pre-import-dialog", "webflow-unknown"] + }, + { + "key": "source", + "type": "string", + "description": "Where the import was launched from.", + "enum": [ + "password_management_promo", + "password_management_empty_state", + "password_management_overflow", + "autofill_settings_button", + "in_browser_promo", + "settings", + "onboarding", + "unknown" + ] + } + ] + }, + "autofill_import_google_passwords_result_success": { + "description": "The Google Password Manager import completed and the credentials were written to the password store.", + "owners": ["catalinradoiu"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + { + "key": "saved_credentials", + "type": "string", + "description": "Number of credentials saved, bucketed.", + "enum": ["none", "few", "some", "many", "lots"] + }, + { + "key": "skipped_credentials", + "type": "string", + "description": "Number of credentials skipped as duplicates, bucketed.", + "enum": ["none", "few", "some", "many", "lots"] + }, + { + "key": "source", + "type": "string", + "description": "Where the import was launched from.", + "enum": [ + "password_management_promo", + "password_management_empty_state", + "password_management_overflow", + "autofill_settings_button", + "in_browser_promo", + "settings", + "onboarding", + "unknown" + ] + } + ] + }, + "autofill_import_google_passwords_result_parsing": { + "description": "The Google Password Manager import failed because the exported CSV of credentials could not be parsed.", + "owners": ["catalinradoiu"], + "triggers": ["exception"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + { + "key": "source", + "type": "string", + "description": "Where the import was launched from.", + "enum": [ + "password_management_promo", + "password_management_empty_state", + "password_management_overflow", + "autofill_settings_button", + "in_browser_promo", + "settings", + "onboarding", + "unknown" + ] + } + ] + }, + "autofill_import_google_passwords_webview_crash": { + "description": "The Google Password Manager import failed because the WebView hosting the import flow crashed.", + "owners": ["catalinradoiu"], + "triggers": ["exception"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + { + "key": "source", + "type": "string", + "description": "Where the import was launched from.", + "enum": [ + "password_management_promo", + "password_management_empty_state", + "password_management_overflow", + "autofill_settings_button", + "in_browser_promo", + "settings", + "onboarding", + "unknown" + ] + } + ] } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt index 636ebd481cf2..acb079d6edeb 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt @@ -18,6 +18,7 @@ package com.duckduckgo.autofill.impl.importing import android.os.Parcelable import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.AutofillImportLaunchSource import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.Finished @@ -38,6 +39,7 @@ interface CredentialImporter { suspend fun import( importList: List, originalImportListSize: Int, + source: AutofillImportLaunchSource, ) fun getImportStatus(): Flow @@ -51,6 +53,7 @@ interface CredentialImporter { data class Finished( val savedCredentials: Int, val numberSkipped: Int, + val source: AutofillImportLaunchSource, ) : ImportResult } } @@ -68,15 +71,17 @@ class CredentialImporterImpl @Inject constructor( override suspend fun import( importList: List, originalImportListSize: Int, + source: AutofillImportLaunchSource, ) { appCoroutineScope.launch(dispatchers.io()) { - doImportCredentials(importList, originalImportListSize) + doImportCredentials(importList, originalImportListSize, source) } } private suspend fun doImportCredentials( importList: List, originalImportListSize: Int, + source: AutofillImportLaunchSource, ) { var skippedCredentials = originalImportListSize - importList.size @@ -89,7 +94,7 @@ class CredentialImporterImpl @Inject constructor( // mark that the user has imported passwords at least once, regardless of the number of credentials imported autofillStore.hasEverImportedPasswords = true - _importStatus.emit(Finished(savedCredentials = insertedIds.size, numberSkipped = skippedCredentials)) + _importStatus.emit(Finished(savedCredentials = insertedIds.size, numberSkipped = skippedCredentials, source = source)) } override fun getImportStatus(): Flow = _importStatus diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserver.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserver.kt new file mode 100644 index 000000000000..7ca0ad6b306e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserver.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 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 + +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Reports the outcome of a successful password import. + * + * The web flow returns as soon as the credentials are handed over, but they are still being written and + * counted after that, so the counts outlive the screen that started the import. Observing app-wide from + * process start keeps this independent of whichever caller launched the flow, and means the replay cache + * is always empty when collection begins — so every import is reported exactly once. + */ +@ContributesMultibinding( + scope = AppScope::class, + boundType = MainProcessLifecycleObserver::class, +) +@SingleInstanceIn(AppScope::class) +class ImportPasswordsResultPixelObserver @Inject constructor( + private val credentialImporter: CredentialImporter, + private val importPasswordsPixelSender: ImportPasswordsPixelSender, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, +) : MainProcessLifecycleObserver { + + override fun onCreate(owner: LifecycleOwner) { + super.onCreate(owner) + + appCoroutineScope.launch(dispatchers.io()) { + credentialImporter.getImportStatus() + .filterIsInstance() + .collect { + importPasswordsPixelSender.onImportSuccessful( + savedCredentials = it.savedCredentials, + numberSkipped = it.numberSkipped, + source = it.source, + ) + } + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt index d03e698a729c..6885222ba775 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowActivity.kt @@ -21,6 +21,8 @@ import android.os.Bundle import androidx.fragment.app.commit import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.autofill.api.AutofillImportLaunchSource +import com.duckduckgo.autofill.api.AutofillImportLaunchSource.Unknown import com.duckduckgo.autofill.api.AutofillScreens.AutofillImportPasswordsScreen import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.databinding.ActivityImportGooglePasswordsWebflowBinding @@ -33,6 +35,7 @@ import com.duckduckgo.common.utils.edgetoedge.EdgeToEdgeBucket import com.duckduckgo.common.utils.edgetoedge.EdgeToEdgeHandler import com.duckduckgo.common.utils.edgetoedge.EdgeToEdgeProvider import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.navigation.api.getActivityParams import javax.inject.Inject @InjectWith(ActivityScope::class) @@ -47,6 +50,9 @@ class ImportGooglePasswordsWebFlowActivity : DuckDuckGoActivity() { val binding: ActivityImportGooglePasswordsWebflowBinding by viewBinding() + private val launchSource: AutofillImportLaunchSource + get() = intent.getActivityParams(AutofillImportPasswordsScreen::class.java)?.source ?: Unknown + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val edgeToEdgeEnabled = edgeToEdgeProvider.isEnabled(EdgeToEdgeBucket.WEBVIEW) @@ -69,7 +75,7 @@ class ImportGooglePasswordsWebFlowActivity : DuckDuckGoActivity() { private fun launchImportFragment() { supportFragmentManager.commit { - replace(R.id.fragment_container, ImportGooglePasswordsWebFlowFragment()) + replace(R.id.fragment_container, ImportGooglePasswordsWebFlowFragment.newInstance(launchSource)) } } 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 34ea7418cd8f..eff00701bf14 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 @@ -25,6 +25,7 @@ import android.webkit.WebSettings import android.webkit.WebView import androidx.activity.OnBackPressedCallback import androidx.appcompat.widget.Toolbar +import androidx.core.os.BundleCompat import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.Lifecycle @@ -34,6 +35,8 @@ import androidx.lifecycle.lifecycleScope import androidx.webkit.WebViewCompat import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin +import com.duckduckgo.autofill.api.AutofillImportLaunchSource +import com.duckduckgo.autofill.api.AutofillImportLaunchSource.Unknown import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory import com.duckduckgo.autofill.api.domain.app.LoginCredentials @@ -50,6 +53,7 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordRe import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.InjectCredentialsFromReauth import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.NoCredentialsAvailable import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.PromptUserToSelectFromStoredCredentials +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Factory import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.Initializing import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.LoadStartPage @@ -68,7 +72,6 @@ import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails import com.duckduckgo.browsermode.api.BrowserMode import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.common.utils.FragmentViewModelFactory import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.user.agent.api.UserAgentProvider @@ -96,7 +99,7 @@ class ImportGooglePasswordsWebFlowFragment : lateinit var dispatchers: DispatcherProvider @Inject - lateinit var viewModelFactory: FragmentViewModelFactory + lateinit var viewModelFactory: Factory @Inject lateinit var credentialAutofillDialogFactory: CredentialAutofillDialogFactory @@ -121,8 +124,14 @@ class ImportGooglePasswordsWebFlowFragment : private var binding: FragmentImportGooglePasswordsWebflowBinding? = null + private val launchSource: AutofillImportLaunchSource + get() = BundleCompat.getParcelable(arguments ?: Bundle(), KEY_LAUNCH_SOURCE, AutofillImportLaunchSource::class.java) ?: Unknown + private val viewModel by lazy { - ViewModelProvider(requireActivity(), viewModelFactory)[ImportGooglePasswordsWebFlowViewModel::class.java] + ViewModelProvider( + requireActivity(), + Factory.Provider(viewModelFactory, launchSource), + )[ImportGooglePasswordsWebFlowViewModel::class.java] } override fun onCreateView( @@ -462,6 +471,13 @@ class ImportGooglePasswordsWebFlowFragment : } companion object { + fun newInstance(source: AutofillImportLaunchSource): ImportGooglePasswordsWebFlowFragment { + return ImportGooglePasswordsWebFlowFragment().apply { + arguments = Bundle().apply { putParcelable(KEY_LAUNCH_SOURCE, source) } + } + } + + private const val KEY_LAUNCH_SOURCE = "launchSource" private const val CUSTOM_FLOW_TAB_ID = "import-passwords-webflow" private const val SELECT_CREDENTIALS_FRAGMENT_TAG = "autofillSelectCredentialsDialog" } 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 9f7a7a2a3db7..e6ff8bc84273 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 @@ -18,9 +18,10 @@ package com.duckduckgo.autofill.impl.importing.gpm.webflow import android.os.Parcelable import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.autofill.api.AutofillImportLaunchSource import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.impl.importing.CredentialImporter @@ -36,8 +37,11 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsW import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails import com.duckduckgo.autofill.impl.store.ReauthenticationHandler +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.FragmentScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -48,10 +52,9 @@ import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import logcat.LogPriority.WARN import logcat.logcat -import javax.inject.Inject -@ContributesViewModel(FragmentScope::class) -class ImportGooglePasswordsWebFlowViewModel @Inject constructor( +class ImportGooglePasswordsWebFlowViewModel @AssistedInject constructor( + @Assisted private val launchSource: AutofillImportLaunchSource, private val dispatchers: DispatcherProvider, private val credentialImporter: CredentialImporter, private val csvCredentialConverter: CsvCredentialConverter, @@ -59,6 +62,7 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( private val urlToStageMapper: ImportGooglePasswordUrlToStageMapper, private val reauthenticationHandler: ReauthenticationHandler, private val autofillFeature: AutofillFeature, + private val importPasswordsPixelSender: ImportPasswordsPixelSender, ) : ViewModel() { private val _viewState = MutableStateFlow(Initializing) @@ -81,17 +85,19 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( } private suspend fun onCsvParsed(parseResult: CsvCredentialImportResult.Success) { - credentialImporter.import(parseResult.loginCredentialsToImport, parseResult.numberCredentialsInSource) + credentialImporter.import(parseResult.loginCredentialsToImport, parseResult.numberCredentialsInSource, launchSource) _viewState.value = ViewState.UserFinishedImportFlow } fun onCsvError() { logcat(WARN) { "Error decoding CSV" } + importPasswordsPixelSender.onImportFailed(ErrorParsingCsv, launchSource) _viewState.value = ViewState.UserFinishedCannotImport(ErrorParsingCsv) } fun onWebViewCrash() { logcat(WARN) { "WebView has crashed during password import flow" } + importPasswordsPixelSender.onImportFailed(WebViewCrash, launchSource) _viewState.value = ViewState.UserFinishedCannotImport(WebViewCrash) } @@ -114,7 +120,9 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( private fun terminateFlowAsCancellation(url: String) { viewModelScope.launch { - _viewState.value = UserCancelledImportFlow(urlToStageMapper.getStage(url)) + val stage = urlToStageMapper.getStage(url) + importPasswordsPixelSender.onUserCancelledImportWebFlow(stage, launchSource) + _viewState.value = UserCancelledImportFlow(stage) } } @@ -251,4 +259,20 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( sealed interface BackButtonAction { data object NavigateBack : BackButtonAction } + + @AssistedFactory + interface Factory { + fun create(launchSource: AutofillImportLaunchSource): ImportGooglePasswordsWebFlowViewModel + + class Provider( + private val assistedFactory: Factory, + private val launchSource: AutofillImportLaunchSource, + ) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return assistedFactory.create(launchSource) as T + } + } + } } 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 index 60da9eb62ab9..e9b6e83309b8 100644 --- 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 @@ -32,7 +32,7 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOO 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.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -49,7 +49,7 @@ interface ImportPasswordsPixelSender { fun onImportPasswordsViaDesktopSyncOverflowMenuTapped() } -@ContributesBinding(FragmentScope::class) +@ContributesBinding(AppScope::class) class ImportPasswordsPixelSenderImpl @Inject constructor( private val pixel: Pixel, private val engagementBucketing: AutofillEngagementBucketing, 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 44d8477d654e..150127b5b076 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 @@ -133,8 +133,7 @@ class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { if (activityResult.resultCode == Activity.RESULT_OK) { lifecycleScope.launch { activityResult.data?.let { data -> - val launchSource = getLaunchSource() - processImportFlowResult(data, launchSource) + processImportFlowResult(data) } } } @@ -143,16 +142,12 @@ class ImportFromGooglePasswordsDialog : BottomSheetDialogFragment() { private fun getLaunchSource() = BundleCompat.getParcelable(arguments ?: Bundle(), KEY_LAUNCH_SOURCE, AutofillImportLaunchSource::class.java) ?: Unknown - private fun ImportFromGooglePasswordsDialog.processImportFlowResult(data: Intent, launchSource: AutofillImportLaunchSource) { + private fun ImportFromGooglePasswordsDialog.processImportFlowResult(data: Intent) { (IntentCompat.getParcelableExtra(data, ImportGooglePasswordResult.RESULT_KEY_DETAILS, ImportGooglePasswordResult::class.java)).let { when (it) { - is ImportGooglePasswordResult.Success -> viewModel.onImportFlowFinishedSuccessfully(launchSource) - is ImportGooglePasswordResult.Error -> viewModel.onImportFlowFinishedWithError(it.reason, launchSource) - is ImportGooglePasswordResult.UserCancelled -> viewModel.onImportFlowCancelledByUser( - it.stage, - canShowPreImportDialog(launchSource), - launchSource, - ) + is ImportGooglePasswordResult.Success -> viewModel.onImportFlowFinishedSuccessfully() + is ImportGooglePasswordResult.Error -> viewModel.onImportFlowFinishedWithError() + is ImportGooglePasswordResult.UserCancelled -> viewModel.onImportFlowCancelledByUser(canShowPreImportDialog(getLaunchSource())) else -> {} } } 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 0408c4ad9ea8..b69ed89a76a5 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 @@ -23,7 +23,6 @@ import com.duckduckgo.autofill.api.AutofillImportLaunchSource import com.duckduckgo.autofill.api.AutofillImportLaunchSource.InBrowserPromo 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.store.InternalAutofillStore import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode.BrowserPromoPreImport @@ -46,13 +45,13 @@ class ImportFromGooglePasswordsDialogViewModel @Inject constructor( private val autofillStore: InternalAutofillStore, ) : ViewModel() { - fun onImportFlowFinishedSuccessfully(importSource: AutofillImportLaunchSource) { + fun onImportFlowFinishedSuccessfully() { viewModelScope.launch(dispatchers.main()) { - observeImportJob(importSource) + observeImportJob() } } - private suspend fun observeImportJob(importSource: AutofillImportLaunchSource) { + private suspend fun observeImportJob() { credentialImporter.getImportStatus().collect { when (it) { is ImportResult.InProgress -> { @@ -62,38 +61,22 @@ class ImportFromGooglePasswordsDialogViewModel @Inject constructor( is ImportResult.Finished -> { logcat { "Import finished: ${it.savedCredentials} imported. ${it.numberSkipped} skipped." } - fireImportSuccessPixel(savedCredentials = it.savedCredentials, numberSkipped = it.numberSkipped, importSource = importSource) _viewState.value = ViewState(viewMode = ViewMode.ImportSuccess(it)) } } } } - fun onImportFlowFinishedWithError(reason: UserCannotImportReason, importSource: AutofillImportLaunchSource) { - fireImportFailedPixel(reason, importSource) + fun onImportFlowFinishedWithError() { _viewState.value = ViewState(viewMode = ViewMode.ImportError) } - fun onImportFlowCancelledByUser(stage: String, canShowPreImportDialog: Boolean, importSource: AutofillImportLaunchSource) { - importPasswordsPixelSender.onUserCancelledImportWebFlow(stage, importSource) - + fun onImportFlowCancelledByUser(canShowPreImportDialog: Boolean) { if (!canShowPreImportDialog) { _viewState.value = ViewState(viewMode = ViewMode.FlowTerminated) } } - private fun fireImportSuccessPixel(savedCredentials: Int, numberSkipped: Int, importSource: AutofillImportLaunchSource) { - importPasswordsPixelSender.onImportSuccessful( - savedCredentials = savedCredentials, - numberSkipped = numberSkipped, - source = importSource, - ) - } - - private fun fireImportFailedPixel(reason: UserCannotImportReason, importSource: AutofillImportLaunchSource) { - importPasswordsPixelSender.onImportFailed(reason, importSource) - } - fun shouldShowInitialInstructionalPrompt(importSource: AutofillImportLaunchSource) { val viewMode = if (importSource == AutofillImportLaunchSource.InBrowserPromo) { logcat { "ImportFromGooglePasswordsDialogViewModel: InBrowserPromo scenario" } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt index 2ec0cd47c4c1..9061840a1702 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt @@ -1,6 +1,7 @@ package com.duckduckgo.autofill.impl.importing import app.cash.turbine.test +import com.duckduckgo.autofill.api.AutofillImportLaunchSource.PasswordManagementEmptyState import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult import com.duckduckgo.autofill.impl.store.InternalAutofillStore @@ -132,7 +133,7 @@ class CredentialImporterImplTest { } private suspend fun List.import(originalListSize: Int = this.size) { - testee.import(this, originalListSize) + testee.import(this, originalListSize, PasswordManagementEmptyState) } private suspend fun assertResult( @@ -143,6 +144,7 @@ class CredentialImporterImplTest { with(awaitItem() as ImportResult.Finished) { assertEquals("Wrong number of duplicates in result", numberSkippedExpected, numberSkipped) assertEquals("Wrong import size in result", importListSizeExpected, savedCredentials) + assertEquals("Wrong launch source in result", PasswordManagementEmptyState, source) } } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserverTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserverTest.kt new file mode 100644 index 000000000000..a7d2b152193c --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserverTest.kt @@ -0,0 +1,59 @@ +package com.duckduckgo.autofill.impl.importing + +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.autofill.api.AutofillImportLaunchSource.Onboarding +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.InProgress +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class ImportPasswordsResultPixelObserverTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val credentialImporter: CredentialImporter = mock() + private val importPasswordsPixelSender: ImportPasswordsPixelSender = mock() + private val lifecycleOwner: LifecycleOwner = mock() + + private val testee = ImportPasswordsResultPixelObserver( + credentialImporter = credentialImporter, + importPasswordsPixelSender = importPasswordsPixelSender, + appCoroutineScope = coroutineTestRule.testScope, + dispatchers = coroutineTestRule.testDispatcherProvider, + ) + + @Test + fun whenNoImportStatusThenNoPixelSent() = runTest { + whenever(credentialImporter.getImportStatus()).thenReturn(emptyFlow()) + testee.onCreate(lifecycleOwner) + verifyNoInteractions(importPasswordsPixelSender) + } + + @Test + fun whenImportStillInProgressThenNoPixelSent() = runTest { + whenever(credentialImporter.getImportStatus()).thenReturn(listOf(InProgress).asFlow()) + testee.onCreate(lifecycleOwner) + verifyNoInteractions(importPasswordsPixelSender) + } + + @Test + fun whenImportFinishedThenSuccessPixelSentWithCountsAndLaunchSource() = runTest { + whenever(credentialImporter.getImportStatus()).thenReturn( + listOf(InProgress, Finished(savedCredentials = 10, numberSkipped = 2, source = Onboarding)).asFlow(), + ) + + testee.onCreate(lifecycleOwner) + + verify(importPasswordsPixelSender).onImportSuccessful(savedCredentials = 10, numberSkipped = 2, source = Onboarding) + } +} 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 81e323930e34..fcc0dcb93e26 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 @@ -3,6 +3,7 @@ package com.duckduckgo.autofill.impl.importing.gpm.webflow import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.autofill.api.AutofillImportLaunchSource.Onboarding import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType.AUTOPROMPT import com.duckduckgo.autofill.api.domain.app.LoginTriggerType.USER_INITIATED @@ -15,6 +16,7 @@ import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPassword import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.InjectCredentialsFromReauth import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.NoCredentialsAvailable import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.PromptUserToSelectFromStoredCredentials +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.ErrorParsingCsv import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.WebViewCrash import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.LoadStartPage import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.NavigatingBack @@ -23,6 +25,7 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsW import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedImportFlow import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails import com.duckduckgo.autofill.impl.store.ReauthenticationHandler +import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.test.runTest @@ -49,8 +52,10 @@ class ImportGooglePasswordsWebFlowViewModelTest { private val urlToStageMapper: ImportGooglePasswordUrlToStageMapper = mock() private val reauthenticationHandler: ReauthenticationHandler = mock() private val autofillFeature: AutofillFeature = mock() + private val importPasswordsPixelSender: ImportPasswordsPixelSender = mock() private val testee = ImportGooglePasswordsWebFlowViewModel( + launchSource = Onboarding, dispatchers = coroutineTestRule.testDispatcherProvider, credentialImporter = credentialImporter, csvCredentialConverter = csvCredentialConverter, @@ -58,6 +63,7 @@ class ImportGooglePasswordsWebFlowViewModelTest { urlToStageMapper = urlToStageMapper, reauthenticationHandler = reauthenticationHandler, autofillFeature = autofillFeature, + importPasswordsPixelSender = importPasswordsPixelSender, ) @Test @@ -329,6 +335,54 @@ class ImportGooglePasswordsWebFlowViewModelTest { } } + @Test + fun whenCloseButtonPressedThenUserCancelledPixelSentWithStageAndLaunchSource() = runTest { + val expectedStage = "stage" + whenever(urlToStageMapper.getStage(any())).thenReturn(expectedStage) + testee.onCloseButtonPressed("https://example.com") + + verify(importPasswordsPixelSender).onUserCancelledImportWebFlow(expectedStage, Onboarding) + } + + @Test + fun whenBackButtonPressedAndCannotGoBackThenUserCancelledPixelSent() = runTest { + val expectedStage = "stage" + whenever(urlToStageMapper.getStage(any())).thenReturn(expectedStage) + testee.onBackButtonPressed(url = "https://example.com", canGoBack = false) + + verify(importPasswordsPixelSender).onUserCancelledImportWebFlow(expectedStage, Onboarding) + } + + @Test + fun whenBackButtonPressedAndCanGoBackThenNoUserCancelledPixelSent() = runTest { + testee.onBackButtonPressed(url = "https://example.com", canGoBack = true) + + verify(importPasswordsPixelSender, never()).onUserCancelledImportWebFlow(any(), any()) + } + + @Test + fun whenCsvParseErrorThenImportFailedPixelSent() = runTest { + configureCsvParseError() + + verify(importPasswordsPixelSender).onImportFailed(ErrorParsingCsv, Onboarding) + } + + @Test + fun whenWebViewCrashesThenImportFailedPixelSent() = runTest { + testee.onWebViewCrash() + + verify(importPasswordsPixelSender).onImportFailed(WebViewCrash, Onboarding) + } + + @Test + fun whenCsvParsedThenImportStartedWithLaunchSource() = runTest { + val credentials = listOf(creds()) + + configureCsvSuccess(loginCredentialsToImport = credentials) + + verify(credentialImporter).import(credentials, credentials.size, Onboarding) + } + private fun configureReAuthenticationFeatureFlagEnabled() { val mockToggle: Toggle = mock() whenever(mockToggle.isEnabled()).thenReturn(true) 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 80b50636cf57..44f15c513b9a 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 @@ -7,7 +7,6 @@ import com.duckduckgo.autofill.api.AutofillImportLaunchSource.InBrowserPromo 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.ErrorParsingCsv import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.ImportPasswordsPixelSender import com.duckduckgo.autofill.impl.ui.credential.management.importpassword.google.ImportFromGooglePasswordsDialogViewModel.ViewMode @@ -55,7 +54,7 @@ class ImportFromGooglePasswordsDialogViewModelTest { @Test fun whenParsingErrorOnImportThenViewModeUpdatedToError() = runTest { - testee.onImportFlowFinishedWithError(reason = ErrorParsingCsv, importSource = TEST_SOURCE) + testee.onImportFlowFinishedWithError() testee.viewState.test { assertTrue(awaitItem().viewMode is ViewMode.ImportError) } @@ -65,7 +64,7 @@ class ImportFromGooglePasswordsDialogViewModelTest { fun whenSuccessfulImportThenViewModeUpdatedToInProgress() = runTest { configureImportInProgress() testee.shouldShowInitialInstructionalPrompt(importSource = TEST_SOURCE) - testee.onImportFlowFinishedSuccessfully(importSource = TEST_SOURCE) + testee.onImportFlowFinishedSuccessfully() testee.viewState.test { awaitImportInProgress() } @@ -75,7 +74,7 @@ class ImportFromGooglePasswordsDialogViewModelTest { fun whenSuccessfulImportFlowThenImportFinishesNothingImportedThenViewModeUpdatedToResults() = runTest { configureImportFinished(savedCredentials = 0, numberSkipped = 0) testee.shouldShowInitialInstructionalPrompt(importSource = TEST_SOURCE) - testee.onImportFlowFinishedSuccessfully(importSource = TEST_SOURCE) + testee.onImportFlowFinishedSuccessfully() testee.viewState.test { awaitImportSuccess() } @@ -85,7 +84,7 @@ class ImportFromGooglePasswordsDialogViewModelTest { fun whenSuccessfulImportFlowThenImportFinishesCredentialsImportedNoDuplicatesThenViewModeUpdatedToResults() = runTest { configureImportFinished(savedCredentials = 10, numberSkipped = 0) testee.shouldShowInitialInstructionalPrompt(importSource = TEST_SOURCE) - testee.onImportFlowFinishedSuccessfully(importSource = TEST_SOURCE) + testee.onImportFlowFinishedSuccessfully() testee.viewState.test { val result = awaitImportSuccess() assertEquals(10, result.importResult.savedCredentials) @@ -97,7 +96,7 @@ class ImportFromGooglePasswordsDialogViewModelTest { fun whenSuccessfulImportFlowThenImportFinishesOnlyDuplicatesThenViewModeUpdatedToResults() = runTest { configureImportFinished(savedCredentials = 0, numberSkipped = 2) testee.shouldShowInitialInstructionalPrompt(importSource = TEST_SOURCE) - testee.onImportFlowFinishedSuccessfully(importSource = TEST_SOURCE) + testee.onImportFlowFinishedSuccessfully() testee.viewState.test { val result = awaitImportSuccess() assertEquals(0, result.importResult.savedCredentials) @@ -108,7 +107,7 @@ class ImportFromGooglePasswordsDialogViewModelTest { @Test fun whenSuccessfulImportNoUpdatesThenThenViewModeFirstInitialisedToPreImport() = runTest { testee.shouldShowInitialInstructionalPrompt(importSource = TEST_SOURCE) - testee.onImportFlowFinishedSuccessfully(importSource = TEST_SOURCE) + testee.onImportFlowFinishedSuccessfully() testee.viewState.test { awaitItem().assertIsPreImport() } @@ -154,7 +153,7 @@ class ImportFromGooglePasswordsDialogViewModelTest { whenever(credentialImporter.getImportStatus()).thenReturn( listOf( InProgress, - Finished(savedCredentials = savedCredentials, numberSkipped = numberSkipped), + Finished(savedCredentials = savedCredentials, numberSkipped = numberSkipped, source = TEST_SOURCE), ).asFlow(), ) } 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 447ad71d8f00..301c1192bbfc 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 @@ -168,6 +168,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { credentialImporter.import( parseResult.loginCredentialsToImport, parseResult.numberCredentialsInSource, + Unknown, ) observePasswordInputUpdates() } From 0a5388f6efc9793d0608857c5d5957a80e18ea9f Mon Sep 17 00:00:00 2001 From: catalinradoiu Date: Tue, 8 Sep 2026 12:43:46 +0300 Subject: [PATCH 2/6] Rename onContentBound to onBeforeContentBound The callback fires at the top of ContentControllerImpl.bind, before the binder renders, so the resolved content state is ready for the bind. The name now reflects that ordering. Co-Authored-By: Claude Opus 5 (1M context) --- .../page/configdriven/ConfigDrivenOnboardingPageViewModel.kt | 2 +- .../ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt | 2 +- .../ui/page/configdriven/engine/ContentController.kt | 4 ++-- .../configdriven/ConfigDrivenOnboardingPageViewModelTest.kt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt index d1e4ef0861ce..aaac4640d36f 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt @@ -214,7 +214,7 @@ class ConfigDrivenOnboardingPageViewModel @Inject constructor( } } - fun onContentBound( + fun onBeforeContentBound( stepId: LinearOnboardingStepId, content: ContentConfig, ) { diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt index bc2f9cbfb295..1f45322bf166 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt @@ -189,7 +189,7 @@ class ConfigDrivenWelcomePageFragment : OnboardingPageFragment(R.layout.content_ content = ContentControllerImpl( binding = binding.daxDialogCta, contentValues = viewModel.contentValues, - onContentBound = viewModel::onContentBound, + onBeforeContentBound = viewModel::onBeforeContentBound, isLightMode = { appTheme.isLightModeEnabled() }, isAddressBarRebrandEnabled = { appBrandDesignUpdateToggles.addressBar().isEnabled() }, ), diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt index b775cc64d9a7..e1d08058a098 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt @@ -56,7 +56,7 @@ interface ContentController { class ContentControllerImpl( private val binding: PreOnboardingDaxDialogCtaBrandDesignUpdateBinding, private val contentValues: ContentValueStore, - private val onContentBound: (LinearOnboardingStepId, ContentConfig) -> Unit, + private val onBeforeContentBound: (LinearOnboardingStepId, ContentConfig) -> Unit, isLightMode: () -> Boolean, isAddressBarRebrandEnabled: () -> Boolean, ) : ContentController { @@ -95,7 +95,7 @@ class ContentControllerImpl( content: ContentConfig, scope: BindScope, ): ContentHandle { - onContentBound(stepId, content) + onBeforeContentBound(stepId, content) val handle = when (content) { is ContentConfig.Welcome -> { boundView = welcome.view diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt index cb2c97688ae2..51eb26fffd00 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt @@ -182,7 +182,7 @@ class ConfigDrivenOnboardingPageViewModelTest { private fun bindContent(testee: ConfigDrivenOnboardingPageViewModel) { val dialog = testee.viewState.value.screen as Screen.Dialog - testee.onContentBound(dialog.stepId, dialog.config.content) + testee.onBeforeContentBound(dialog.stepId, dialog.config.content) } private fun importCompleteState(testee: ConfigDrivenOnboardingPageViewModel): MutableStateFlow { From 72203d3ace5c2625fde3a6d5b0a92db542fa3d54 Mon Sep 17 00:00:00 2001 From: catalinradoiu Date: Thu, 10 Sep 2026 12:31:11 +0300 Subject: [PATCH 3/6] restart CI From 7326aa1786f0133bdeb916f6b6b13ca792938c6c Mon Sep 17 00:00:00 2001 From: catalinradoiu Date: Tue, 8 Sep 2026 13:43:28 +0300 Subject: [PATCH 4/6] Add metrics for the passwords import onboarding experiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the onboarding completion metric for passwordImportExperimentAug25, fired when the user reaches the established stage, plus the import started, success, failed and cancelled metrics. The import metrics fire from the root of the import flow — the web flow view model and the app-scoped import result observer — rather than from each caller, so imports are counted for both cohorts and from every entry point. The experiment lives in the onboarding feature, which autofill-impl cannot depend on, so the toggle is resolved through the feature toggles inventory by name. Task/Issue URL: https://app.asana.com/1/137249556945/project/72649045549333/task/1217594019546143?focus=true Co-Authored-By: Claude Opus 5 (1M context) # Conflicts: # app/src/main/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserver.kt # app/src/test/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserverTest.kt --- .../OnboardingCompletedMetricObserver.kt | 2 + ...boardingPasswordImportExperimentMetrics.kt | 49 ++++++++ .../OnboardingCompletedMetricObserverTest.kt | 4 + ...dingPasswordImportExperimentMetricsTest.kt | 62 ++++++++++ .../ImportPasswordsResultPixelObserver.kt | 2 + .../PasswordImportExperimentMetrics.kt | 83 +++++++++++++ .../ImportGooglePasswordsWebFlowViewModel.kt | 6 + .../ImportPasswordsResultPixelObserverTest.kt | 31 +++++ .../PasswordImportExperimentMetricsTest.kt | 115 ++++++++++++++++++ ...portGooglePasswordsWebFlowViewModelTest.kt | 42 +++++++ 10 files changed, 396 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/OnboardingPasswordImportExperimentMetrics.kt create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/OnboardingPasswordImportExperimentMetricsTest.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImportExperimentMetrics.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/PasswordImportExperimentMetricsTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserver.kt index 0df56736e70d..6c4fd88389fb 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserver.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserver.kt @@ -38,6 +38,7 @@ class OnboardingCompletedMetricObserver @Inject constructor( private val userStageStore: UserStageStore, private val onboardingPromptsExperimentMetrics: OnboardingPromptsExperimentMetrics, private val segmentedOnboardingExperimentMetrics: SegmentedOnboardingExperimentMetrics, + private val onboardingPasswordImportExperimentMetrics: OnboardingPasswordImportExperimentMetrics, ) : MainProcessLifecycleObserver { override fun onCreate(owner: LifecycleOwner) { @@ -46,6 +47,7 @@ class OnboardingCompletedMetricObserver @Inject constructor( .onEach { onboardingPromptsExperimentMetrics.fireOnboardingCompletedMetric() segmentedOnboardingExperimentMetrics.fireOnboardingCompletedMetric() + onboardingPasswordImportExperimentMetrics.fireOnboardingCompletedMetric() } .launchIn(appCoroutineScope) } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingPasswordImportExperimentMetrics.kt b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingPasswordImportExperimentMetrics.kt new file mode 100644 index 000000000000..0942c151506d --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingPasswordImportExperimentMetrics.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 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.app.onboarding + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.ConversionWindow +import com.duckduckgo.feature.toggles.api.MetricType +import com.duckduckgo.feature.toggles.api.MetricsPixel +import com.duckduckgo.feature.toggles.api.send +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface OnboardingPasswordImportExperimentMetrics { + + suspend fun fireOnboardingCompletedMetric() +} + +@ContributesBinding(AppScope::class) +class OnboardingPasswordImportExperimentMetricsImpl @Inject constructor( + private val toggles: OnboardingPasswordImportToggles, +) : OnboardingPasswordImportExperimentMetrics { + + override suspend fun fireOnboardingCompletedMetric() { + MetricsPixel( + metric = "onboarding_completed", + type = MetricType.NORMAL, + value = "1", + toggle = toggles.passwordImportExperimentAug25(), + conversionWindow = listOf( + ConversionWindow(lowerWindow = 0, upperWindow = 0), + ConversionWindow(lowerWindow = 0, upperWindow = 14), + ), + ).send() + } +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserverTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserverTest.kt index 929f6e3c875a..f97a8488886d 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserverTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingCompletedMetricObserverTest.kt @@ -38,6 +38,7 @@ class OnboardingCompletedMetricObserverTest { private val userStageStore: UserStageStore = mock { on { userAppStageFlow() } doReturn appStageFlow } private val metrics: OnboardingPromptsExperimentMetrics = mock() private val segmentedMetrics: SegmentedOnboardingExperimentMetrics = mock() + private val passwordImportMetrics: OnboardingPasswordImportExperimentMetrics = mock() private val lifecycleOwner: LifecycleOwner = mock() private val testee = OnboardingCompletedMetricObserver( @@ -45,6 +46,7 @@ class OnboardingCompletedMetricObserverTest { userStageStore = userStageStore, onboardingPromptsExperimentMetrics = metrics, segmentedOnboardingExperimentMetrics = segmentedMetrics, + onboardingPasswordImportExperimentMetrics = passwordImportMetrics, ) @Test @@ -54,6 +56,7 @@ class OnboardingCompletedMetricObserverTest { appStageFlow.emit(AppStage.ESTABLISHED) verify(metrics).fireOnboardingCompletedMetric() + verify(passwordImportMetrics).fireOnboardingCompletedMetric() } @Test @@ -64,6 +67,7 @@ class OnboardingCompletedMetricObserverTest { appStageFlow.emit(AppStage.DAX_ONBOARDING) verify(metrics, never()).fireOnboardingCompletedMetric() + verify(passwordImportMetrics, never()).fireOnboardingCompletedMetric() } @Test diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingPasswordImportExperimentMetricsTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingPasswordImportExperimentMetricsTest.kt new file mode 100644 index 000000000000..8786766c03c8 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingPasswordImportExperimentMetricsTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 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.app.onboarding + +import android.annotation.SuppressLint +import com.duckduckgo.feature.toggles.api.ConversionWindow +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.FakeMetricsPixelExtension +import com.duckduckgo.feature.toggles.api.MetricType +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@SuppressLint("DenyListedApi") +class OnboardingPasswordImportExperimentMetricsTest { + + private val fakeMetricsPixelExtension = FakeMetricsPixelExtension() + private val toggles = FakeFeatureToggleFactory.create(OnboardingPasswordImportToggles::class.java) + private lateinit var metrics: OnboardingPasswordImportExperimentMetrics + + @Before + fun setup() { + fakeMetricsPixelExtension.register() + metrics = OnboardingPasswordImportExperimentMetricsImpl(toggles = toggles) + } + + @Test + fun `when fireOnboardingCompletedMetric then sends onboarding_completed d0 and d0-14 NORMAL metric for the experiment toggle`() = runTest { + metrics.fireOnboardingCompletedMetric() + + val sent = fakeMetricsPixelExtension.sentMetrics.single() + assertEquals("onboarding_completed", sent.metric) + assertEquals("1", sent.value) + assertEquals(MetricType.NORMAL, sent.type) + assertEquals( + listOf( + ConversionWindow(lowerWindow = 0, upperWindow = 0), + ConversionWindow(lowerWindow = 0, upperWindow = 14), + ), + sent.conversionWindow, + ) + assertEquals( + toggles.passwordImportExperimentAug25().featureName().name, + sent.toggle.featureName().name, + ) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserver.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserver.kt index 7ca0ad6b306e..96b89e236a4e 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserver.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserver.kt @@ -46,6 +46,7 @@ import javax.inject.Inject class ImportPasswordsResultPixelObserver @Inject constructor( private val credentialImporter: CredentialImporter, private val importPasswordsPixelSender: ImportPasswordsPixelSender, + private val passwordImportExperimentMetrics: PasswordImportExperimentMetrics, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatchers: DispatcherProvider, ) : MainProcessLifecycleObserver { @@ -62,6 +63,7 @@ class ImportPasswordsResultPixelObserver @Inject constructor( numberSkipped = it.numberSkipped, source = it.source, ) + passwordImportExperimentMetrics.fireImportSuccessMetric() } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImportExperimentMetrics.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImportExperimentMetrics.kt new file mode 100644 index 000000000000..b72193a578d8 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImportExperimentMetrics.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 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 + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.ConversionWindow +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.api.MetricType +import com.duckduckgo.feature.toggles.api.MetricsPixel +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.send +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +/** + * Experiment metrics for importing passwords from Google, fired for every entry point into the + * import web flow so that onboarding and non-onboarding imports are comparable across cohorts. + */ +interface PasswordImportExperimentMetrics { + + suspend fun fireImportStartedMetric() + + suspend fun fireImportSuccessMetric() + + suspend fun fireImportFailedMetric() + + suspend fun fireImportCancelledMetric() +} + +@ContributesBinding(AppScope::class) +class PasswordImportExperimentMetricsImpl @Inject constructor( + private val inventory: FeatureTogglesInventory, +) : PasswordImportExperimentMetrics { + + override suspend fun fireImportStartedMetric() = fire("password_import_started") + + override suspend fun fireImportSuccessMetric() = fire("password_import_success") + + override suspend fun fireImportFailedMetric() = fire("password_import_failed") + + override suspend fun fireImportCancelledMetric() = fire("password_import_cancelled") + + private suspend fun fire(metric: String) { + val toggle = experimentToggle() ?: return + MetricsPixel( + metric = metric, + type = MetricType.NORMAL, + value = "1", + toggle = toggle, + conversionWindow = listOf( + ConversionWindow(lowerWindow = 0, upperWindow = 0), + ConversionWindow(lowerWindow = 0, upperWindow = 14), + ), + ).send() + } + + // The experiment is declared in the onboarding feature, which this module cannot depend on, so it + // is resolved through the inventory by name instead. + private suspend fun experimentToggle(): Toggle? { + return inventory.getAllTogglesForParent(PARENT_FEATURE_NAME).firstOrNull { + it.featureName().name == EXPERIMENT_FEATURE_NAME + } + } + + private companion object { + const val PARENT_FEATURE_NAME = "onboardingPasswordImport" + const val EXPERIMENT_FEATURE_NAME = "passwordImportExperimentAug25" + } +} 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 e6ff8bc84273..7eb7e662d97c 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 @@ -27,6 +27,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginTriggerType 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.PasswordImportExperimentMetrics import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.InjectCredentialsFromReauth import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.NoCredentialsAvailable @@ -63,6 +64,7 @@ class ImportGooglePasswordsWebFlowViewModel @AssistedInject constructor( private val reauthenticationHandler: ReauthenticationHandler, private val autofillFeature: AutofillFeature, private val importPasswordsPixelSender: ImportPasswordsPixelSender, + private val passwordImportExperimentMetrics: PasswordImportExperimentMetrics, ) : ViewModel() { private val _viewState = MutableStateFlow(Initializing) @@ -74,6 +76,7 @@ class ImportGooglePasswordsWebFlowViewModel @AssistedInject constructor( fun onViewCreated() { viewModelScope.launch(dispatchers.io()) { _viewState.value = ViewState.LoadStartPage(autofillImportConfigStore.getConfig().launchUrlGooglePasswords) + passwordImportExperimentMetrics.fireImportStartedMetric() } } @@ -93,12 +96,14 @@ class ImportGooglePasswordsWebFlowViewModel @AssistedInject constructor( logcat(WARN) { "Error decoding CSV" } importPasswordsPixelSender.onImportFailed(ErrorParsingCsv, launchSource) _viewState.value = ViewState.UserFinishedCannotImport(ErrorParsingCsv) + viewModelScope.launch { passwordImportExperimentMetrics.fireImportFailedMetric() } } fun onWebViewCrash() { logcat(WARN) { "WebView has crashed during password import flow" } importPasswordsPixelSender.onImportFailed(WebViewCrash, launchSource) _viewState.value = ViewState.UserFinishedCannotImport(WebViewCrash) + viewModelScope.launch { passwordImportExperimentMetrics.fireImportFailedMetric() } } fun onCloseButtonPressed(url: String?) { @@ -123,6 +128,7 @@ class ImportGooglePasswordsWebFlowViewModel @AssistedInject constructor( val stage = urlToStageMapper.getStage(url) importPasswordsPixelSender.onUserCancelledImportWebFlow(stage, launchSource) _viewState.value = UserCancelledImportFlow(stage) + passwordImportExperimentMetrics.fireImportCancelledMetric() } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserverTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserverTest.kt index a7d2b152193c..1a32ddf6b0c7 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserverTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/ImportPasswordsResultPixelObserverTest.kt @@ -23,11 +23,13 @@ class ImportPasswordsResultPixelObserverTest { private val credentialImporter: CredentialImporter = mock() private val importPasswordsPixelSender: ImportPasswordsPixelSender = mock() + private val passwordImportExperimentMetrics: PasswordImportExperimentMetrics = mock() private val lifecycleOwner: LifecycleOwner = mock() private val testee = ImportPasswordsResultPixelObserver( credentialImporter = credentialImporter, importPasswordsPixelSender = importPasswordsPixelSender, + passwordImportExperimentMetrics = passwordImportExperimentMetrics, appCoroutineScope = coroutineTestRule.testScope, dispatchers = coroutineTestRule.testDispatcherProvider, ) @@ -46,6 +48,35 @@ class ImportPasswordsResultPixelObserverTest { verifyNoInteractions(importPasswordsPixelSender) } + @Test + fun whenImportStillInProgressThenNoExperimentMetricFired() = runTest { + whenever(credentialImporter.getImportStatus()).thenReturn(listOf(InProgress).asFlow()) + testee.onCreate(lifecycleOwner) + verifyNoInteractions(passwordImportExperimentMetrics) + } + + @Test + fun whenImportFinishedThenImportSuccessMetricFired() = runTest { + whenever(credentialImporter.getImportStatus()).thenReturn( + listOf(InProgress, Finished(savedCredentials = 10, numberSkipped = 2, source = Onboarding)).asFlow(), + ) + + testee.onCreate(lifecycleOwner) + + verify(passwordImportExperimentMetrics).fireImportSuccessMetric() + } + + @Test + fun whenImportFinishedWithNoCredentialsSavedThenImportSuccessMetricStillFired() = runTest { + whenever(credentialImporter.getImportStatus()).thenReturn( + listOf(Finished(savedCredentials = 0, numberSkipped = 3, source = Onboarding)).asFlow(), + ) + + testee.onCreate(lifecycleOwner) + + verify(passwordImportExperimentMetrics).fireImportSuccessMetric() + } + @Test fun whenImportFinishedThenSuccessPixelSentWithCountsAndLaunchSource() = runTest { whenever(credentialImporter.getImportStatus()).thenReturn( diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/PasswordImportExperimentMetricsTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/PasswordImportExperimentMetricsTest.kt new file mode 100644 index 000000000000..c5089e61133a --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/PasswordImportExperimentMetricsTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 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 + +import com.duckduckgo.feature.toggles.api.ConversionWindow +import com.duckduckgo.feature.toggles.api.FakeMetricsPixelExtension +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.api.MetricType +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.FeatureName +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class PasswordImportExperimentMetricsTest { + + private val fakeMetricsPixelExtension = FakeMetricsPixelExtension() + private val inventory: FeatureTogglesInventory = mock() + private val experimentToggle: Toggle = mock { + on { featureName() } doReturn FeatureName(parentName = "onboardingPasswordImport", name = "passwordImportExperimentAug25") + } + private lateinit var testee: PasswordImportExperimentMetrics + + @Before + fun setup() { + fakeMetricsPixelExtension.register() + testee = PasswordImportExperimentMetricsImpl(inventory = inventory) + } + + @Test + fun whenImportStartedThenSendsStartedMetricForExperimentToggle() = runTest { + givenExperimentEnrolled() + + testee.fireImportStartedMetric() + + assertSentMetric("password_import_started") + } + + @Test + fun whenImportSuccessThenSendsSuccessMetricForExperimentToggle() = runTest { + givenExperimentEnrolled() + + testee.fireImportSuccessMetric() + + assertSentMetric("password_import_success") + } + + @Test + fun whenImportFailedThenSendsFailedMetricForExperimentToggle() = runTest { + givenExperimentEnrolled() + + testee.fireImportFailedMetric() + + assertSentMetric("password_import_failed") + } + + @Test + fun whenImportCancelledThenSendsCancelledMetricForExperimentToggle() = runTest { + givenExperimentEnrolled() + + testee.fireImportCancelledMetric() + + assertSentMetric("password_import_cancelled") + } + + @Test + fun whenExperimentToggleNotInInventoryThenNoMetricSent() = runTest { + whenever(inventory.getAllTogglesForParent("onboardingPasswordImport")).thenReturn(emptyList()) + + testee.fireImportStartedMetric() + testee.fireImportSuccessMetric() + testee.fireImportFailedMetric() + testee.fireImportCancelledMetric() + + assertTrue(fakeMetricsPixelExtension.sentMetrics.isEmpty()) + } + + private suspend fun givenExperimentEnrolled() { + whenever(inventory.getAllTogglesForParent("onboardingPasswordImport")).thenReturn(listOf(experimentToggle)) + } + + private fun assertSentMetric(metric: String) { + val sent = fakeMetricsPixelExtension.sentMetrics.single() + assertEquals(metric, sent.metric) + assertEquals("1", sent.value) + assertEquals(MetricType.NORMAL, sent.type) + assertEquals( + listOf( + ConversionWindow(lowerWindow = 0, upperWindow = 0), + ConversionWindow(lowerWindow = 0, upperWindow = 14), + ), + sent.conversionWindow, + ) + assertEquals(experimentToggle, sent.toggle) + } +} 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 fcc0dcb93e26..461eb2d62e05 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 @@ -11,6 +11,7 @@ import com.duckduckgo.autofill.impl.importing.CredentialImporter import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult.Error import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult.Success +import com.duckduckgo.autofill.impl.importing.PasswordImportExperimentMetrics 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.Command.InjectCredentialsFromReauth @@ -53,6 +54,7 @@ class ImportGooglePasswordsWebFlowViewModelTest { private val reauthenticationHandler: ReauthenticationHandler = mock() private val autofillFeature: AutofillFeature = mock() private val importPasswordsPixelSender: ImportPasswordsPixelSender = mock() + private val passwordImportExperimentMetrics: PasswordImportExperimentMetrics = mock() private val testee = ImportGooglePasswordsWebFlowViewModel( launchSource = Onboarding, @@ -64,6 +66,7 @@ class ImportGooglePasswordsWebFlowViewModelTest { reauthenticationHandler = reauthenticationHandler, autofillFeature = autofillFeature, importPasswordsPixelSender = importPasswordsPixelSender, + passwordImportExperimentMetrics = passwordImportExperimentMetrics, ) @Test @@ -383,6 +386,45 @@ class ImportGooglePasswordsWebFlowViewModelTest { verify(credentialImporter).import(credentials, credentials.size, Onboarding) } + @Test + fun whenOnViewCreatedThenImportStartedMetricFired() = runTest { + configureFeature() + testee.onViewCreated() + verify(passwordImportExperimentMetrics).fireImportStartedMetric() + } + + @Test + fun whenCsvParseErrorThenImportFailedMetricFired() = runTest { + configureCsvParseError() + verify(passwordImportExperimentMetrics).fireImportFailedMetric() + } + + @Test + fun whenWebViewCrashesThenImportFailedMetricFired() = runTest { + testee.onWebViewCrash() + verify(passwordImportExperimentMetrics).fireImportFailedMetric() + } + + @Test + fun whenCloseButtonPressedThenImportCancelledMetricFired() = runTest { + whenever(urlToStageMapper.getStage(any())).thenReturn("stage") + testee.onCloseButtonPressed("https://example.com") + verify(passwordImportExperimentMetrics).fireImportCancelledMetric() + } + + @Test + fun whenBackButtonPressedAndCannotGoBackThenImportCancelledMetricFired() = runTest { + whenever(urlToStageMapper.getStage(any())).thenReturn("stage") + testee.onBackButtonPressed(url = "https://example.com", canGoBack = false) + verify(passwordImportExperimentMetrics).fireImportCancelledMetric() + } + + @Test + fun whenBackButtonPressedAndCanGoBackThenImportCancelledMetricNotFired() = runTest { + testee.onBackButtonPressed(url = "https://example.com", canGoBack = true) + verify(passwordImportExperimentMetrics, never()).fireImportCancelledMetric() + } + private fun configureReAuthenticationFeatureFlagEnabled() { val mockToggle: Toggle = mock() whenever(mockToggle.isEnabled()).thenReturn(true) From 7416b052d4e4155549467016ae85d464eed4c7be Mon Sep 17 00:00:00 2001 From: catalinradoiu Date: Tue, 8 Sep 2026 13:56:05 +0300 Subject: [PATCH 5/6] Add the import experiment metrics to the pixel registry Registers passwordImportExperimentAug25 and its five metrics under activeExperiments, so the pixels sent by the native experiments framework are documented. Task/Issue URL: https://app.asana.com/1/137249556945/project/72649045549333/task/1217594019546143?focus=true Co-Authored-By: Claude Opus 5 (1M context) --- .../pixels/native_experiments.json5 | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/PixelDefinitions/pixels/native_experiments.json5 b/PixelDefinitions/pixels/native_experiments.json5 index de9e5ce7f0c0..f67b350d63d7 100644 --- a/PixelDefinitions/pixels/native_experiments.json5 +++ b/PixelDefinitions/pixels/native_experiments.json5 @@ -42,6 +42,31 @@ "enum": ["1", "4", "6", "11", "21", "30"] } } + }, + "passwordImportExperimentAug25": { + "cohorts": ["control", "treatment"], + "metrics": { + "onboarding_completed": { + "description": "User saw every onboarding dialog (onboarding completed)", + "enum": ["1"] + }, + "password_import_started": { + "description": "User entered the Google password import web flow, from the onboarding step or any other entry point", + "enum": ["1"] + }, + "password_import_success": { + "description": "A Google password import finished, from the onboarding step or any other entry point", + "enum": ["1"] + }, + "password_import_failed": { + "description": "A Google password import failed with a CSV parsing error or a WebView crash", + "enum": ["1"] + }, + "password_import_cancelled": { + "description": "User closed or backed out of the Google password import web flow", + "enum": ["1"] + } + } } } } From 0e21feda300430714ed4e605ecb3594cc12ff272 Mon Sep 17 00:00:00 2001 From: catalinradoiu Date: Fri, 11 Sep 2026 14:01:49 +0300 Subject: [PATCH 6/6] Fix Prettier formatting in native_experiments.json5 Co-Authored-By: Claude Sonnet 5 --- .../pixels/native_experiments.json5 | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/PixelDefinitions/pixels/native_experiments.json5 b/PixelDefinitions/pixels/native_experiments.json5 index f67b350d63d7..721c16f9cab0 100644 --- a/PixelDefinitions/pixels/native_experiments.json5 +++ b/PixelDefinitions/pixels/native_experiments.json5 @@ -44,29 +44,29 @@ } }, "passwordImportExperimentAug25": { - "cohorts": ["control", "treatment"], - "metrics": { - "onboarding_completed": { - "description": "User saw every onboarding dialog (onboarding completed)", - "enum": ["1"] - }, - "password_import_started": { - "description": "User entered the Google password import web flow, from the onboarding step or any other entry point", - "enum": ["1"] - }, - "password_import_success": { - "description": "A Google password import finished, from the onboarding step or any other entry point", - "enum": ["1"] - }, - "password_import_failed": { - "description": "A Google password import failed with a CSV parsing error or a WebView crash", - "enum": ["1"] - }, - "password_import_cancelled": { - "description": "User closed or backed out of the Google password import web flow", - "enum": ["1"] + "cohorts": ["control", "treatment"], + "metrics": { + "onboarding_completed": { + "description": "User saw every onboarding dialog (onboarding completed)", + "enum": ["1"] + }, + "password_import_started": { + "description": "User entered the Google password import web flow, from the onboarding step or any other entry point", + "enum": ["1"] + }, + "password_import_success": { + "description": "A Google password import finished, from the onboarding step or any other entry point", + "enum": ["1"] + }, + "password_import_failed": { + "description": "A Google password import failed with a CSV parsing error or a WebView crash", + "enum": ["1"] + }, + "password_import_cancelled": { + "description": "User closed or backed out of the Google password import web flow", + "enum": ["1"] + } } - } } } }