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"
]
}
]
}
}
25 changes: 25 additions & 0 deletions PixelDefinitions/pixels/native_experiments.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -46,6 +47,7 @@ class OnboardingCompletedMetricObserver @Inject constructor(
.onEach {
onboardingPromptsExperimentMetrics.fireOnboardingCompletedMetric()
segmentedOnboardingExperimentMetrics.fireOnboardingCompletedMetric()
onboardingPasswordImportExperimentMetrics.fireOnboardingCompletedMetric()
}
.launchIn(appCoroutineScope)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
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,
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 @@ -38,13 +38,15 @@ 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(
appCoroutineScope = coroutineRule.testScope,
userStageStore = userStageStore,
onboardingPromptsExperimentMetrics = metrics,
segmentedOnboardingExperimentMetrics = segmentedMetrics,
onboardingPasswordImportExperimentMetrics = passwordImportMetrics,
)

@Test
Expand All @@ -54,6 +56,7 @@ class OnboardingCompletedMetricObserverTest {
appStageFlow.emit(AppStage.ESTABLISHED)

verify(metrics).fireOnboardingCompletedMetric()
verify(passwordImportMetrics).fireOnboardingCompletedMetric()
}

@Test
Expand All @@ -64,6 +67,7 @@ class OnboardingCompletedMetricObserverTest {
appStageFlow.emit(AppStage.DAX_ONBOARDING)

verify(metrics, never()).fireOnboardingCompletedMetric()
verify(passwordImportMetrics, never()).fireOnboardingCompletedMetric()
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Loading
Loading