Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions PixelDefinitions/pixels/definitions/autofill.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ class ConfigDrivenOnboardingPageViewModel @Inject constructor(
}
}

fun onContentBound(
fun onBeforeContentBound(
stepId: LinearOnboardingStepId,
content: ContentConfig,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up after #9624 (comment)

isLightMode: () -> Boolean,
isAddressBarRebrandEnabled: () -> Boolean,
) : ContentController {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImportCompleteContentState> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +39,7 @@ interface CredentialImporter {
suspend fun import(
importList: List<LoginCredentials>,
originalImportListSize: Int,
source: AutofillImportLaunchSource,
)

fun getImportStatus(): Flow<ImportResult>
Expand All @@ -51,6 +53,7 @@ interface CredentialImporter {
data class Finished(
val savedCredentials: Int,
val numberSkipped: Int,
val source: AutofillImportLaunchSource,
) : ImportResult
}
}
Expand All @@ -68,15 +71,17 @@ class CredentialImporterImpl @Inject constructor(
override suspend fun import(
importList: List<LoginCredentials>,
originalImportListSize: Int,
source: AutofillImportLaunchSource,
) {
appCoroutineScope.launch(dispatchers.io()) {
doImportCredentials(importList, originalImportListSize)
doImportCredentials(importList, originalImportListSize, source)
}
}

private suspend fun doImportCredentials(
importList: List<LoginCredentials>,
originalImportListSize: Int,
source: AutofillImportLaunchSource,
) {
var skippedCredentials = originalImportListSize - importList.size

Expand All @@ -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<ImportResult> = _importStatus
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Finished>()
.collect {
importPasswordsPixelSender.onImportSuccessful(
savedCredentials = it.savedCredentials,
numberSkipped = it.numberSkipped,
source = it.source,
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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))
}
}

Expand Down
Loading
Loading