From 28f11e5c26c186f1f2f5a19e2b71f171ebfab503 Mon Sep 17 00:00:00 2001 From: catalinradoiu Date: Mon, 31 Aug 2026 18:34:44 +0300 Subject: [PATCH 1/3] 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/3] 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/3] restart CI