Skip to content
Draft
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
13 changes: 11 additions & 2 deletions app/src/main/java/com/duckduckgo/app/launch/LaunchViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.duckduckgo.app.launch
import androidx.lifecycle.ViewModel
import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.app.global.install.AppInstallStore
import com.duckduckgo.app.notificationpromptexperiment.NotificationPromptExperimentManager
import com.duckduckgo.app.onboarding.store.UserStageStore
import com.duckduckgo.app.onboarding.store.isNewUser
import com.duckduckgo.app.onboardingdesignexperiment.OnboardingDesignExperimentManager
Expand All @@ -43,6 +44,7 @@ class LaunchViewModel @Inject constructor(
private val daxPrompts: DaxPrompts,
private val appInstallStore: AppInstallStore,
private val onboardingDesignExperimentManager: OnboardingDesignExperimentManager,
private val notificationPromptExperimentManager: NotificationPromptExperimentManager,
) :
ViewModel() {

Expand All @@ -56,13 +58,20 @@ class LaunchViewModel @Inject constructor(
}

suspend fun determineViewToShow() {
if (onboardingDesignExperimentManager.isWaitForLocalPrivacyConfigEnabled()) {
val waitForLocalPrivacyOnboardingExperiment = onboardingDesignExperimentManager.isWaitForLocalPrivacyConfigEnabled()
val waitForLocalPrivacyNotificationExperiment = notificationPromptExperimentManager.isWaitForLocalPrivacyConfigEnabled()
if (waitForLocalPrivacyOnboardingExperiment || waitForLocalPrivacyNotificationExperiment) {
withTimeoutOrNull(MAX_REFERRER_WAIT_TIME_MS) {
val referrerJob = async {
waitForReferrerData()
}
val configJob = async {
onboardingDesignExperimentManager.waitForPrivacyConfig()
if (waitForLocalPrivacyOnboardingExperiment) {
onboardingDesignExperimentManager.waitForPrivacyConfig()
}
if (waitForLocalPrivacyNotificationExperiment) {
notificationPromptExperimentManager.waitForPrivacyConfig()
}
}
awaitAll(referrerJob, configJob)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 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.notificationpromptexperiment

import android.os.Build
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.notificationpromptexperiment.NotificationPromptExperimentToggles.Cohorts.CONTROL
import com.duckduckgo.app.notificationpromptexperiment.NotificationPromptExperimentToggles.Cohorts.VARIANT_NO_NOTIFICATION_PROMPT
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.MetricsPixel
import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject

interface NotificationPromptExperimentManager {

suspend fun enroll()
fun isControl(): Boolean
fun isExperimentalNoNotificationPrompt(): Boolean
suspend fun waitForPrivacyConfig(): Boolean
suspend fun isWaitForLocalPrivacyConfigEnabled(): Boolean
suspend fun fireDdgSetAsDefault()
suspend fun fireNotifyMeClickedLater()
suspend fun fireNotificationsEnabledLater()
}

@ContributesMultibinding(
scope = AppScope::class,
boundType = PrivacyConfigCallbackPlugin::class,
)
@ContributesBinding(
scope = AppScope::class,
boundType = NotificationPromptExperimentManager::class,
)
@SingleInstanceIn(AppScope::class)
class NotificationPromptExperimentManagerImpl @Inject constructor(
private val dispatcherProvider: DispatcherProvider,
private val notificationPromptExperimentToggles: NotificationPromptExperimentToggles,
private val notificationPromptExperimentPixelsPlugin: NotificationPromptExperimentPixelsPlugin,
private val pixel: Pixel,
private val appBuildConfig: AppBuildConfig,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
) : NotificationPromptExperimentManager, PrivacyConfigCallbackPlugin {

private var isExperimentEnabled: Boolean? = null
private var notificationPromptExperimentCohort: NotificationPromptExperimentToggles.Cohorts? = null

private var privacyPersisted: Boolean = false

override suspend fun enroll() {
if (appBuildConfig.sdkInt >= Build.VERSION_CODES.TIRAMISU && !appBuildConfig.isAppReinstall()) {
notificationPromptExperimentToggles.notificationPromptExperimentOct25().enroll()
}
}

override fun isControl(): Boolean =
isExperimentEnabled == true &&
notificationPromptExperimentCohort == CONTROL

override fun isExperimentalNoNotificationPrompt(): Boolean =
isExperimentEnabled == true &&
notificationPromptExperimentCohort == VARIANT_NO_NOTIFICATION_PROMPT

override suspend fun waitForPrivacyConfig(): Boolean {
while (!privacyPersisted) {
delay(10)
}
return true
}

override suspend fun isWaitForLocalPrivacyConfigEnabled(): Boolean = notificationPromptExperimentToggles.waitForLocalPrivacyConfig().isEnabled()

override suspend fun fireDdgSetAsDefault() {
withContext(dispatcherProvider.io()) {
notificationPromptExperimentPixelsPlugin.getDdgSetAsDefaultMetric()?.fire()
}
}

override suspend fun fireNotifyMeClickedLater() {
withContext(dispatcherProvider.io()) {
notificationPromptExperimentPixelsPlugin.getNotifyMeClickedLaterMetric()?.fire()
}
}

override suspend fun fireNotificationsEnabledLater() {
withContext(dispatcherProvider.io()) {
notificationPromptExperimentPixelsPlugin.getNotificationsEnabledLaterMetric()?.fire()
}
}

override fun onPrivacyConfigPersisted() {
privacyPersisted = true
coroutineScope.launch {
setCachedProperties()
}
}

override fun onPrivacyConfigDownloaded() {
coroutineScope.launch {
setCachedProperties()
}
}

private suspend fun setCachedProperties() {
withContext(dispatcherProvider.io()) {
enroll()
notificationPromptExperimentCohort = getEnrolledAndEnabledExperimentCohort()
isExperimentEnabled =
notificationPromptExperimentToggles.self().isEnabled() && notificationPromptExperimentToggles.notificationPromptExperimentOct25()
.isEnabled()
}
}

private suspend fun getEnrolledAndEnabledExperimentCohort(): NotificationPromptExperimentToggles.Cohorts? {
val cohort = notificationPromptExperimentToggles.notificationPromptExperimentOct25().getCohort()

return when (cohort?.name) {
CONTROL.cohortName -> CONTROL
VARIANT_NO_NOTIFICATION_PROMPT.cohortName -> VARIANT_NO_NOTIFICATION_PROMPT
else -> null
}
}

private fun MetricsPixel.fire() = getPixelDefinitions().forEach {
pixel.fire(it.pixelName, it.params)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright (c) 2025 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.notificationpromptexperiment

import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
import com.duckduckgo.app.notificationpromptexperiment.NotificationPromptExperimentToggles.Companion.BASE_EXPERIMENT_NAME
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.ConversionWindow
import com.duckduckgo.feature.toggles.api.MetricsPixel
import com.duckduckgo.feature.toggles.api.MetricsPixelPlugin
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue
import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import javax.inject.Inject

@ContributesRemoteFeature(
scope = AppScope::class,
featureName = BASE_EXPERIMENT_NAME,
)
interface NotificationPromptExperimentToggles {

/**
* Toggle to enable/disable the "self" notification prompt experiment.
* Default value: false (disabled).
*/
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun self(): Toggle

/**
* Toggle to enable/disable the "sub-feature" notification prompt experiment.
* Default value: false (disabled).
*/
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun notificationPromptExperimentOct25(): Toggle

/**
* Toggle to enable/disable the "sub-feature" waitForLocalPrivacyConfig.
* Default value: true (enabled).
*/
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun waitForLocalPrivacyConfig(): Toggle

enum class Cohorts(override val cohortName: String) : CohortName {
// Current experience, where the Notification Prompt is shown at app start after fresh install.
CONTROL("control"),

// New experience, where the Notification Prompt is never shown at app start after fresh install.
VARIANT_NO_NOTIFICATION_PROMPT("experimentalNoNotificationPrompt"),
}

companion object {
internal const val BASE_EXPERIMENT_NAME = "notificationPromptExperiment"
}
}

@ContributesMultibinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class NotificationPromptExperimentPixelsPlugin @Inject constructor(
private val toggles: NotificationPromptExperimentToggles,
) : MetricsPixelPlugin {

override suspend fun getMetrics(): List<MetricsPixel> {
return listOf(
MetricsPixel(
metric = METRIC_NOTIFICATION_PROMPT_DDG_SET_AS_DEFAULT,
value = "1",
toggle = toggles.notificationPromptExperimentOct25(),
conversionWindow = listOf(
ConversionWindow(lowerWindow = 0, upperWindow = 0),
),
),
MetricsPixel(
metric = METRIC_NOTIFICATION_PROMPT_NOTIFY_ME_CLICKED_LATER,
value = "1",
toggle = toggles.notificationPromptExperimentOct25(),
conversionWindow = listOf(
ConversionWindow(lowerWindow = 0, upperWindow = 0),
ConversionWindow(lowerWindow = 1, upperWindow = 1),
ConversionWindow(lowerWindow = 2, upperWindow = 7),
ConversionWindow(lowerWindow = 8, upperWindow = 14),
),
),
MetricsPixel(
metric = METRIC_NOTIFICATION_PROMPT_NOTIFICATIONS_ENABLED_LATER,
value = "1",
toggle = toggles.notificationPromptExperimentOct25(),
conversionWindow = listOf(
ConversionWindow(lowerWindow = 0, upperWindow = 0),
ConversionWindow(lowerWindow = 1, upperWindow = 1),
ConversionWindow(lowerWindow = 2, upperWindow = 7),
ConversionWindow(lowerWindow = 8, upperWindow = 14),
),
),
)
}

suspend fun getDdgSetAsDefaultMetric(): MetricsPixel? {
return this.getMetrics().firstOrNull { it.metric == METRIC_NOTIFICATION_PROMPT_DDG_SET_AS_DEFAULT }
}

suspend fun getNotifyMeClickedLaterMetric(): MetricsPixel? {
return this.getMetrics().firstOrNull { it.metric == METRIC_NOTIFICATION_PROMPT_NOTIFY_ME_CLICKED_LATER }
}

suspend fun getNotificationsEnabledLaterMetric(): MetricsPixel? {
return this.getMetrics().firstOrNull { it.metric == METRIC_NOTIFICATION_PROMPT_NOTIFICATIONS_ENABLED_LATER }
}

companion object {
internal const val METRIC_NOTIFICATION_PROMPT_DDG_SET_AS_DEFAULT = "ddgSetAsDefault"
internal const val METRIC_NOTIFICATION_PROMPT_NOTIFY_ME_CLICKED_LATER = "notifyMeClickedLater"
internal const val METRIC_NOTIFICATION_PROMPT_NOTIFICATIONS_ENABLED_LATER = "notificationsEnabledLater"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import androidx.transition.TransitionManager
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.app.browser.R
import com.duckduckgo.app.browser.databinding.ContentOnboardingWelcomePageBinding
import com.duckduckgo.app.notificationpromptexperiment.NotificationPromptExperimentManager
import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.ADDRESS_BAR_POSITION
import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.COMPARISON_CHART
import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INITIAL
Expand Down Expand Up @@ -80,6 +81,9 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p
@Inject
lateinit var onboardingDesignExperimentManager: OnboardingDesignExperimentManager

@Inject
lateinit var notificationPromptExperimentManager: NotificationPromptExperimentManager

private val binding: ContentOnboardingWelcomePageBinding by viewBinding()
private val viewModel by lazy {
ViewModelProvider(this, viewModelFactory)[WelcomePageViewModel::class.java]
Expand Down Expand Up @@ -184,8 +188,12 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p
@SuppressLint("InlinedApi")
private fun requestNotificationsPermissions() {
if (appBuildConfig.sdkInt >= android.os.Build.VERSION_CODES.TIRAMISU) {
viewModel.notificationRuntimePermissionRequested()
requestPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
if (notificationPromptExperimentManager.isControl()) {
viewModel.notificationRuntimePermissionRequested()
requestPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
scheduleWelcomeAnimation()
}
} else {
scheduleWelcomeAnimation()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@
"readme": "https://github.com/duckduckgo/privacy-configuration",
"version": 1652275711516,
"features": {
"notificationPromptExperiment": {
"state": "enabled",
"features": {
"notificationPromptExperimentOct25": {
"state": "enabled",
"cohorts": [
{
"name": "control",
"weight": 1
},
{
"name": "experimentalNoNotificationPrompt",
"weight": 1
}
]
}
}
},
"onboardingDesignExperiment": {
"state": "enabled",
"features": {
Expand Down
Loading