diff --git a/app/src/main/java/com/duckduckgo/app/launch/LaunchViewModel.kt b/app/src/main/java/com/duckduckgo/app/launch/LaunchViewModel.kt index 703aa714d37d..a9d74f88977d 100644 --- a/app/src/main/java/com/duckduckgo/app/launch/LaunchViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/launch/LaunchViewModel.kt @@ -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 @@ -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() { @@ -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) } diff --git a/app/src/main/java/com/duckduckgo/app/notificationpromptexperiment/NotificationPromptExperimentManager.kt b/app/src/main/java/com/duckduckgo/app/notificationpromptexperiment/NotificationPromptExperimentManager.kt new file mode 100644 index 000000000000..370fef9f881e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/notificationpromptexperiment/NotificationPromptExperimentManager.kt @@ -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) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/notificationpromptexperiment/NotificationPromptExperimentToggles.kt b/app/src/main/java/com/duckduckgo/app/notificationpromptexperiment/NotificationPromptExperimentToggles.kt new file mode 100644 index 000000000000..b4ff61826d80 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/notificationpromptexperiment/NotificationPromptExperimentToggles.kt @@ -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 { + 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" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt index 48c9ff98e853..e74850eab115 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt @@ -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 @@ -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] @@ -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() } diff --git a/privacy-config/privacy-config-impl/src/main/res/raw/privacy_config.json b/privacy-config/privacy-config-impl/src/main/res/raw/privacy_config.json index 7d1ee7fbaac0..59619d230421 100644 --- a/privacy-config/privacy-config-impl/src/main/res/raw/privacy_config.json +++ b/privacy-config/privacy-config-impl/src/main/res/raw/privacy_config.json @@ -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": {