From 10e7976b053acd1821513cb9ad323f90261e3f69 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Mon, 23 Dec 2024 17:18:21 +0000 Subject: [PATCH 01/16] Added free trial experiment sub-feature and cohorts --- .../duckduckgo/subscriptions/impl/RealSubscriptions.kt | 9 +++++++++ .../subscriptions/impl/SubscriptionsConstants.kt | 4 ++-- .../impl/ui/SubscriptionWebViewViewModel.kt | 9 +++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt index 93ffac1c18a5..e14d1832bedf 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt @@ -34,6 +34,7 @@ import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.subscriptions.api.Product import com.duckduckgo.subscriptions.api.SubscriptionStatus @@ -160,6 +161,14 @@ interface PrivacyProFeature { // Kill switch @Toggle.DefaultValue(true) fun featuresApi(): Toggle + + @Toggle.DefaultValue(false) + fun privacyProFreeTrialJan25(): Toggle + + enum class Cohorts(override val cohortName: String) : CohortName { + CONTROL("control"), + TREATMENT("treatment"), + } } @ContributesBinding(AppScope::class) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt index 252d093aca5c..15fa414a5ae9 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt @@ -34,8 +34,8 @@ object SubscriptionsConstants { const val MONTHLY_PLAN_ROW = "ddg-privacy-pro-monthly-renews-row" // List of offers - const val MONTHLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-sandbox-freetrial-monthly-renews-us" - const val YEARLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-sandbox-freetrial-yearly-renews-us" + const val MONTHLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-sandbox-freetrial-monthly-renews-us" // TODO NOELIA change when experiment added + const val YEARLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-sandbox-freetrial-yearly-renews-us" // TODO NOELIA change when experiment added // List of features const val LEGACY_FE_NETP = "vpn" diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index e2906cf82b12..17a2c1707d4f 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -30,6 +30,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.impl.CurrentPurchase import com.duckduckgo.subscriptions.impl.JSONObjectAdapter import com.duckduckgo.subscriptions.impl.PrivacyProFeature +import com.duckduckgo.subscriptions.impl.PrivacyProFeature.Cohorts import com.duckduckgo.subscriptions.impl.SubscriptionOffer import com.duckduckgo.subscriptions.impl.SubscriptionsChecker import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ITR @@ -255,6 +256,14 @@ class SubscriptionWebViewViewModel @Inject constructor( val subscriptionOptions = if (privacyProFeature.allowPurchase().isEnabled()) { val subscriptionOffers = subscriptionsManager.getSubscriptionOffer().associateBy { it.offerId ?: it.planId } when { + subscriptionOffers.keys.containsAll(listOf(MONTHLY_FREE_TRIAL_OFFER_US, YEARLY_FREE_TRIAL_OFFER_US)) && + privacyProFeature.privacyProFreeTrialJan25().isEnabled(Cohorts.TREATMENT) -> { + createSubscriptionOptions( + monthlyOffer = subscriptionOffers.getValue(MONTHLY_FREE_TRIAL_OFFER_US), + yearlyOffer = subscriptionOffers.getValue(YEARLY_FREE_TRIAL_OFFER_US), + ) + } + subscriptionOffers.keys.containsAll(listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)) -> { createSubscriptionOptions( monthlyOffer = subscriptionOffers.getValue(MONTHLY_PLAN_US), From dcc77f607641044c45449a3d68171793b98fca4d Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Mon, 23 Dec 2024 19:21:47 +0000 Subject: [PATCH 02/16] Added ConfirmationBody model changes for supporting experiment metrics in BE --- .../duckduckgo/subscriptions/impl/SubscriptionsManager.kt | 7 +++++++ .../subscriptions/impl/services/SubscriptionsService.kt | 2 ++ 2 files changed, 9 insertions(+) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index dc438f697bb9..dd80a1e6a75d 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -406,11 +406,18 @@ class RealSubscriptionsManager @Inject constructor( packageName: String, purchaseToken: String, ): Boolean { + var experimentName: String? = null + val cohort: String? = privacyProFeature.get().privacyProFreeTrialJan25().getCohort()?.name + if (cohort != null) { + experimentName = "privacyProFreeTrialJan25" + } return try { val confirmationResponse = subscriptionsService.confirm( ConfirmationBody( packageName = packageName, purchaseToken = purchaseToken, + experimentName = experimentName, + experimentCohort = cohort, ), ) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt index e133741d11ac..b07e4c88b195 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt @@ -64,6 +64,8 @@ data class SubscriptionResponse( data class ConfirmationBody( val packageName: String, val purchaseToken: String, + val experimentName: String?, + val experimentCohort: String?, ) data class ConfirmationResponse( From 273c4a7f08b42db2ed77316725fa8c153c01e25d Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Sat, 28 Dec 2024 17:40:05 +0000 Subject: [PATCH 03/16] Added TODO to check for previous free trials purchases --- .../subscriptions/impl/ui/SubscriptionWebViewViewModel.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index 17a2c1707d4f..5da899ea2d0f 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -256,8 +256,7 @@ class SubscriptionWebViewViewModel @Inject constructor( val subscriptionOptions = if (privacyProFeature.allowPurchase().isEnabled()) { val subscriptionOffers = subscriptionsManager.getSubscriptionOffer().associateBy { it.offerId ?: it.planId } when { - subscriptionOffers.keys.containsAll(listOf(MONTHLY_FREE_TRIAL_OFFER_US, YEARLY_FREE_TRIAL_OFFER_US)) && - privacyProFeature.privacyProFreeTrialJan25().isEnabled(Cohorts.TREATMENT) -> { + subscriptionOffers.keys.containsAll(listOf(MONTHLY_FREE_TRIAL_OFFER_US, YEARLY_FREE_TRIAL_OFFER_US)) && isFreeTrialEligible() -> { createSubscriptionOptions( monthlyOffer = subscriptionOffers.getValue(MONTHLY_FREE_TRIAL_OFFER_US), yearlyOffer = subscriptionOffers.getValue(YEARLY_FREE_TRIAL_OFFER_US), @@ -288,6 +287,11 @@ class SubscriptionWebViewViewModel @Inject constructor( } } + private fun isFreeTrialEligible(): Boolean { + val hasUsedFreeTrial: Boolean = true // TODO Noelia check previous purchases + return privacyProFeature.privacyProFreeTrialJan25().isEnabled(Cohorts.TREATMENT) && hasUsedFreeTrial + } + private fun createSubscriptionOptions( monthlyOffer: SubscriptionOffer, yearlyOffer: SubscriptionOffer, From 2234bb57dc58645ee92da8200d1ea5ad01a1b434 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Thu, 2 Jan 2025 13:03:32 +0100 Subject: [PATCH 04/16] Implemented metricPixelPlugin for free trial pixels --- .../freetrial/FreeTrialExperimentDataStore.kt | 74 ++++++++++ .../FreeTrialPrivacyProMetricsPixelPlugin.kt | 137 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt new file mode 100644 index 000000000000..43ac3b37338c --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2024 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.subscriptions.impl.freetrial + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.PixelDefinition +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext + +interface FreeTrialExperimentDataStore { + /** + * @return number of times paywall has been displayed to user + */ + var paywallImpressions: Int + + /** + * Increases the count of paywall impressions + */ + suspend fun increaseMetricForPaywallImpressions() + + /** + * Returns the number [Int] of paywall impressions for the given [definition] + */ + suspend fun getMetricForPixelDefinition(definition: PixelDefinition): Int +} + +@ContributesBinding(AppScope::class) +internal class FreeTrialExperimentDataStoreImpl @Inject constructor( + private val context: Context, + private val dispatcherProvider: DispatcherProvider, +) : FreeTrialExperimentDataStore { + private val preferences: SharedPreferences by lazy { context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE) } + + override var paywallImpressions: Int + get() = preferences.getInt(KEY_PAYWALL_IMPRESSIONS, 0) + set(impressions) = preferences.edit { putInt(KEY_PAYWALL_IMPRESSIONS, impressions) } + + override suspend fun increaseMetricForPaywallImpressions() { + withContext(dispatcherProvider.io()) { + paywallImpressions += 1 + } + } + + override suspend fun getMetricForPixelDefinition(definition: PixelDefinition): Int { + val tag = "$definition" + return withContext(dispatcherProvider.io()) { + preferences.getInt(tag, 0) + } + } + + companion object { + private const val FILENAME = "com.duckduckgo.subscriptions.freetrial.store" + private const val KEY_PAYWALL_IMPRESSIONS = "PAYWALL_IMPRESSIONS" + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt new file mode 100644 index 000000000000..28e7b4844dc4 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024 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.subscriptions.impl.freetrial + +import com.duckduckgo.app.statistics.pixels.Pixel +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.PixelDefinition +import com.duckduckgo.subscriptions.impl.PrivacyProFeature +import com.squareup.anvil.annotations.ContributesMultibinding +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +internal suspend fun FreeTrialPrivacyProPixelsPlugin.onPaywallImpression() { + val metricPixel = this.getMetrics().firstOrNull { it.metric == "paywallImpressions" } + this.firePixelFor(metricPixel) +} + +internal suspend fun FreeTrialPrivacyProPixelsPlugin.onStartClickedMonthly() { + val metricPixel = this.getMetrics().firstOrNull { it.metric == "startClickedMonthly" } + this.firePixelFor(metricPixel) +} + +internal suspend fun FreeTrialPrivacyProPixelsPlugin.onStartClickedYearly() { + val metricPixel = this.getMetrics().firstOrNull { it.metric == "startClickedYearly" } + this.firePixelFor(metricPixel) +} + +internal suspend fun FreeTrialPrivacyProPixelsPlugin.onSubscriptionStartedMonthly() { + val metricPixel = this.getMetrics().firstOrNull { it.metric == "subscriptionStartedMonthly" } + this.firePixelFor(metricPixel) +} + +internal suspend fun FreeTrialPrivacyProPixelsPlugin.onSubscriptionStartedYearly() { + val metricPixel = this.getMetrics().firstOrNull { it.metric == "subscriptionStartedYearly" } + this.firePixelFor(metricPixel) +} + +@ContributesMultibinding(AppScope::class) +class FreeTrialPrivacyProPixelsPlugin @Inject constructor( + private val toggle: PrivacyProFeature, + private val freeTrialExperimentDataStore: FreeTrialExperimentDataStore, + private val pixel: Pixel, +) : MetricsPixelPlugin { + + override suspend fun getMetrics(): List { + return listOf( + MetricsPixel( + metric = "paywallImpressions", + value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), + toggle = toggle.privacyProFreeTrialJan25(), + conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), + ), + MetricsPixel( + metric = "startClickedMonthly", + value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), + toggle = toggle.privacyProFreeTrialJan25(), + conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), + ), + MetricsPixel( + metric = "startClickedYearly", + value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), + toggle = toggle.privacyProFreeTrialJan25(), + conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), + ), + MetricsPixel( + metric = "subscriptionStartedMonthly", + value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), + toggle = toggle.privacyProFreeTrialJan25(), + conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), + ), + MetricsPixel( + metric = "subscriptionStartedYearly", + value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), + toggle = toggle.privacyProFreeTrialJan25(), + conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), + ), + ) + } + + private fun getMetricsPixelValue(paywallImpressions: Int): String { + return when (paywallImpressions) { + 1, 2, 3, 4, 5 -> paywallImpressions.toString() + in 6..10 -> "6-10" + in 11..50 -> "11-50" + in 50..Int.MAX_VALUE -> "51+" + else -> "0" + } + } + + internal suspend fun firePixelFor(metricsPixel: MetricsPixel?) { + metricsPixel?.let { metric -> + metric.getPixelDefinitions().forEach { definition -> + if (definition.isInConversionWindow()) { + freeTrialExperimentDataStore.getMetricForPixelDefinition(definition).takeIf { it < metric.value.toInt() }?.let { + pixel.fire(definition.pixelName, definition.params) + } + } + } + } + } +} + +private fun PixelDefinition.isInConversionWindow(): Boolean { + val enrollmentDate = this.params["enrollmentDate"] ?: return false + val lowerWindow = this.params["conversionWindowDays"]?.split("-")?.first()?.toInt() ?: return false + val upperWindow = this.params["conversionWindowDays"]?.split("-")?.last()?.toInt() ?: return false + val daysDiff = enrollmentDate.daysUntilToday() + + return (daysDiff in lowerWindow..upperWindow) +} + +private fun String.daysUntilToday(): Long { + val today = ZonedDateTime.now(ZoneId.of("America/New_York")) + val localDate = LocalDate.parse(this) + val zoneDateTime: ZonedDateTime = localDate.atStartOfDay(ZoneId.of("America/New_York")) + return ChronoUnit.DAYS.between(zoneDateTime, today) +} From b2d699626090f36b4f8bab8ddaaba97bb60a2478 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Thu, 2 Jan 2025 18:14:24 +0100 Subject: [PATCH 05/16] Added tests --- .../freetrial/FreeTrialExperimentDataStore.kt | 2 +- .../FreeTrialPrivacyProMetricsPixelPlugin.kt | 2 +- .../FreeTrialPrivacyProPixelsPluginTest.kt | 128 ++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt index 43ac3b37338c..2acac8db9ead 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt @@ -44,7 +44,7 @@ interface FreeTrialExperimentDataStore { } @ContributesBinding(AppScope::class) -internal class FreeTrialExperimentDataStoreImpl @Inject constructor( +class FreeTrialExperimentDataStoreImpl @Inject constructor( private val context: Context, private val dispatcherProvider: DispatcherProvider, ) : FreeTrialExperimentDataStore { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt index 28e7b4844dc4..99e158d671be 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt @@ -97,7 +97,7 @@ class FreeTrialPrivacyProPixelsPlugin @Inject constructor( ) } - private fun getMetricsPixelValue(paywallImpressions: Int): String { + internal fun getMetricsPixelValue(paywallImpressions: Int): String { return when (paywallImpressions) { 1, 2, 3, 4, 5 -> paywallImpressions.toString() in 6..10 -> "6-10" diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt new file mode 100644 index 000000000000..21526a877bb6 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt @@ -0,0 +1,128 @@ +/* + * 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.subscriptions.impl.freetrial + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.FeatureToggles +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.subscriptions.impl.PrivacyProFeature +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock + +class FreeTrialPrivacyProPixelsPluginTest { + @get:Rule + @Suppress("unused") + val coroutineRule = CoroutineTestRule() + + private val mockPixel: Pixel = mock() + private val mockFreeTrialExperimentDataStore: FreeTrialExperimentDataStore = mock() + + private lateinit var testFeature: PrivacyProFeature + private lateinit var testee: FreeTrialPrivacyProPixelsPlugin + + @Before + fun setup() { + testFeature = FeatureToggles.Builder( + FakeToggleStore(), + featureName = "testFeature", + ).build().create(PrivacyProFeature::class.java) + + testFeature.privacyProFreeTrialJan25().setRawStoredState(State(enable = true)) + + testee = FreeTrialPrivacyProPixelsPlugin( + toggle = testFeature, + freeTrialExperimentDataStore = mockFreeTrialExperimentDataStore, + pixel = mockPixel, + ) + } + + @Test + fun whenPaywallImpressionsIsZeroThenSetMetricsPixelValueAs0String() = runTest { + val value = testee.getMetricsPixelValue(0) + + assertEquals(value, "0") + } + + @Test + fun whenPaywallImpressionsIsThreeThenSetMetricsPixelValueAs3String() = runTest { + val value = testee.getMetricsPixelValue(3) + + assertEquals(value, "3") + } + + @Test + fun whenPaywallImpressionsIsFiveThenSetMetricsPixelValueAs5String() = runTest { + val value = testee.getMetricsPixelValue(5) + + assertEquals(value, "5") + } + + @Test + fun whenPaywallImpressionsIsSixThenSetMetricsPixelValueWithCorrectValue() = runTest { + val value = testee.getMetricsPixelValue(6) + + assertEquals(value, "6-10") + } + + @Test + fun whenPaywallImpressionsIsEightThenSetMetricsPixelValueWithCorrectValue() = runTest { + val value = testee.getMetricsPixelValue(8) + + assertEquals(value, "6-10") + } + + @Test + fun whenPaywallImpressionsIsTenThenSetMetricsPixelValueWithCorrectValue() = runTest { + val value = testee.getMetricsPixelValue(10) + + assertEquals(value, "6-10") + } + + @Test + fun whenPaywallImpressionsIsElevenThenSetMetricsPixelValueWithCorrectValue() = runTest { + val value = testee.getMetricsPixelValue(11) + + assertEquals(value, "11-50") + } + + @Test + fun whenPaywallImpressionsIsThirtyThenSetMetricsPixelValueWithCorrectValue() = runTest { + val value = testee.getMetricsPixelValue(30) + + assertEquals(value, "11-50") + } + + @Test + fun whenPaywallImpressionsIsFiftyThenSetMetricsPixelValueWithCorrectValue() = runTest { + val value = testee.getMetricsPixelValue(50) + + assertEquals(value, "11-50") + } + + @Test + fun whenPaywallImpressionsIsGreaterThanFiftyThenSetMetricsPixelValueWithCorrectValue() = runTest { + val value = testee.getMetricsPixelValue(60) + + assertEquals(value, "51+") + } +} From d8e756be5287384136744c2161ef32e8ed1f1b8e Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Thu, 13 Feb 2025 18:16:22 +0000 Subject: [PATCH 06/16] Support new activeOffers parameter in SubscriptionResponse and updated SubscriptionSettings screen accordingly --- .../subscriptions/api/Subscriptions.kt | 4 + .../impl/SubscriptionsManager.kt | 11 +++ .../impl/repository/AuthRepository.kt | 5 ++ .../impl/services/SubscriptionsService.kt | 5 ++ .../impl/store/SubscriptionsDataStore.kt | 10 +++ .../impl/ui/SubscriptionSettingsActivity.kt | 37 +++++++--- .../impl/ui/SubscriptionSettingsViewModel.kt | 3 + .../layout/activity_subscription_settings.xml | 73 ++++++++++--------- .../src/main/res/values/donottranslate.xml | 24 ++++++ .../impl/RealSubscriptionsManagerTest.kt | 4 + 10 files changed, 131 insertions(+), 45 deletions(-) create mode 100644 subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml diff --git a/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt index e8fee1710a3c..502f55a41d66 100644 --- a/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt +++ b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt @@ -94,3 +94,7 @@ enum class SubscriptionStatus(val statusName: String) { UNKNOWN("Unknown"), WAITING("Waiting"), } + +enum class ActiveOfferType { + TRIAL, UNKNOWN +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index dd80a1e6a75d..2640cb0e1a17 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -24,6 +24,7 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.api.ActiveOfferType import com.duckduckgo.subscriptions.api.Product import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE @@ -427,6 +428,7 @@ class RealSubscriptionsManager @Inject constructor( expiresOrRenewsAt = confirmationResponse.subscription.expiresOrRenewsAt, status = confirmationResponse.subscription.status.toStatus(), platform = confirmationResponse.subscription.platform, + activeOffers = confirmationResponse.subscription.activeOffers.map { it.type.toActiveOfferType() }, ) authRepository.setSubscription(subscription) @@ -524,6 +526,7 @@ class RealSubscriptionsManager @Inject constructor( expiresOrRenewsAt = subscription.expiresOrRenewsAt, status = subscription.status.toStatus(), platform = subscription.platform, + activeOffers = subscription.activeOffers.map { it.type.toActiveOfferType() }, ), ) authRepository.setEntitlements(accountData.entitlements.toEntitlements()) @@ -589,6 +592,7 @@ class RealSubscriptionsManager @Inject constructor( expiresOrRenewsAt = subscription.expiresOrRenewsAt, status = subscription.status.toStatus(), platform = subscription.platform, + activeOffers = subscription.activeOffers.map { it.type.toActiveOfferType() }, ), ) @@ -1016,6 +1020,13 @@ fun String.toStatus(): SubscriptionStatus { } } +fun String.toActiveOfferType(): ActiveOfferType { + return when (this) { + "Trial" -> ActiveOfferType.TRIAL + else -> ActiveOfferType.UNKNOWN + } +} + sealed class CurrentPurchase { data object PreFlowInProgress : CurrentPurchase() data object PreFlowFinished : CurrentPurchase() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt index ea94da63795b..6202b409e48f 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt @@ -19,6 +19,7 @@ package com.duckduckgo.subscriptions.impl.repository import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.data.store.api.SharedPreferencesProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.api.ActiveOfferType import com.duckduckgo.subscriptions.api.Product import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE @@ -166,6 +167,7 @@ internal class RealAuthRepository constructor( startedAt = subscription?.startedAt expiresOrRenewsAt = subscription?.expiresOrRenewsAt status = subscription?.status?.statusName + freeTrialActive = subscription?.activeOffers?.contains(ActiveOfferType.TRIAL) ?: false } } @@ -175,12 +177,14 @@ internal class RealAuthRepository constructor( val startedAt = subscriptionsDataStore.startedAt ?: return@withContext null val expiresOrRenewsAt = subscriptionsDataStore.expiresOrRenewsAt ?: return@withContext null val status = subscriptionsDataStore.status?.toStatus() ?: return@withContext null + val activeOffers = if (subscriptionsDataStore.freeTrialActive) listOf(ActiveOfferType.TRIAL) else listOf() Subscription( productId = productId, platform = platform, startedAt = startedAt, expiresOrRenewsAt = expiresOrRenewsAt, status = status, + activeOffers = activeOffers, ) } @@ -249,6 +253,7 @@ data class Subscription( val expiresOrRenewsAt: Long, val status: SubscriptionStatus, val platform: String, + val activeOffers: List, ) { fun isActive(): Boolean = status.isActive() } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt index b07e4c88b195..890843283425 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt @@ -59,6 +59,11 @@ data class SubscriptionResponse( val expiresOrRenewsAt: Long, val platform: String, val status: String, + val activeOffers: List, +) + +data class ActiveOfferResponse( + val type: String, ) data class ConfirmationBody( diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt index 130dc0ba2a66..a3cf30a8f83e 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt @@ -41,6 +41,7 @@ interface SubscriptionsDataStore { var status: String? var entitlements: String? var productId: String? + var freeTrialActive: Boolean var subscriptionFeatures: String? @@ -123,6 +124,14 @@ internal class SubscriptionsEncryptedDataStore constructor( } } + override var freeTrialActive: Boolean + get() = encryptedPreferences?.getBoolean(KEY_FREE_TRIAL_ACTIVE, false) ?: false + set(value) { + encryptedPreferences?.edit(commit = true) { + putBoolean(KEY_FREE_TRIAL_ACTIVE, value) + } + } + override var accessToken: String? get() = encryptedPreferences?.getString(KEY_ACCESS_TOKEN, null) set(value) { @@ -217,5 +226,6 @@ internal class SubscriptionsEncryptedDataStore constructor( const val KEY_STATUS = "KEY_STATUS" const val KEY_PRODUCT_ID = "KEY_PRODUCT_ID" const val KEY_SUBSCRIPTION_FEATURES = "KEY_SUBSCRIPTION_FEATURES" + const val KEY_FREE_TRIAL_ACTIVE = "KEY_FREE_TRIAL_ACTIVE" } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt index 3f3393241bad..d9acaaa0e7e8 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt @@ -36,6 +36,7 @@ import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.subscriptions.api.ActiveOfferType import com.duckduckgo.subscriptions.api.PrivacyProFeedbackScreens.PrivacyProFeedbackScreenWithParams import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.SUBSCRIPTION_SETTINGS import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE @@ -176,17 +177,35 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { binding.subscriptionActiveStatusContainer.isVisible = true binding.subscriptionExpiredStatusContainer.isVisible = false - val status = when (viewState.status) { - AUTO_RENEWABLE -> getString(string.renews) - else -> getString(string.expires) - } + // Free Trial active + if (viewState.activeOffers.contains(ActiveOfferType.TRIAL)) { + binding.subscriptionActiveStatusTextView.text = getString(string.subscriptionStatusFreeTrial) - val subscriptionsDataStringResId = when (viewState.duration) { - Monthly -> string.subscriptionsDataMonthly - Yearly -> string.subscriptionsDataYearly - } + val subscriptionRenewalDetailsRes = when { + viewState.status == AUTO_RENEWABLE && viewState.duration == Monthly -> + getString(string.freeTrialActiveSubscriptionsData, viewState.date, getString(string.monthly)) + viewState.status == AUTO_RENEWABLE && viewState.duration == Yearly -> + getString(string.freeTrialActiveSubscriptionsData, viewState.date, getString(string.yearly)) + else -> getString(string.freeTrialCancelledSubscriptionsData) + } + binding.changePlan.setSecondaryText(subscriptionRenewalDetailsRes) - binding.changePlan.setSecondaryText(getString(subscriptionsDataStringResId, status, viewState.date)) + // Active status without a Free Trial + } else { + binding.subscriptionActiveStatusTextView.text = getString(string.subscriptionStatusSubscribed) + + val status = when (viewState.status) { + AUTO_RENEWABLE -> getString(string.renews) + else -> getString(string.expires) + } + + val subscriptionsDataStringResId = when (viewState.duration) { + Monthly -> string.subscriptionsDataMonthly + Yearly -> string.subscriptionsDataYearly + } + + binding.changePlan.setSecondaryText(getString(subscriptionsDataStringResId, status, viewState.date)) + } when (viewState.platform.lowercase()) { "apple", "ios" -> diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt index aeec41bb9839..01b3d22613a7 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.subscriptions.api.ActiveOfferType import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.SUBSCRIPTION_SETTINGS import com.duckduckgo.subscriptions.api.SubscriptionStatus @@ -98,6 +99,7 @@ class SubscriptionSettingsViewModel @Inject constructor( platform = subscription.platform, email = account.email?.takeUnless { it.isBlank() }, showFeedback = privacyProUnifiedFeedback.shouldUseUnifiedFeedback(source = SUBSCRIPTION_SETTINGS), + activeOffers = subscription.activeOffers, ), ) } @@ -152,6 +154,7 @@ class SubscriptionSettingsViewModel @Inject constructor( val platform: String, val email: String?, val showFeedback: Boolean = false, + val activeOffers: List, ) : ViewState() } } diff --git a/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml b/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml index 20eaa691eb66..7454933e2fbc 100644 --- a/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml +++ b/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml @@ -15,34 +15,34 @@ --> + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + tools:context="com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsActivity"> + android:id="@+id/includeToolbar" + layout="@layout/include_default_toolbar" /> + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fillViewport="true"> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingBottom="@dimen/keyline_5"> + android:src="@drawable/ic_privacy_pro_settings_hero" /> @@ -97,11 +97,12 @@ android:src="@drawable/ic_dot_green" /> + app:typography="body2" /> @@ -111,8 +112,8 @@ android:layout_marginTop="@dimen/keyline_4" /> + android:id="@+id/removeDevice" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:primaryText="@string/subscriptionSettingRemoveFromDevice" /> + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:primaryText="@string/subscriptionSettingSectionHelpAndSupport" /> + android:id="@+id/faq" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:primaryText="@string/privacyProFaq" + app:secondaryText="@string/privacyProFaqSecondary" /> + + + + Free Trial Active + monthly + yearly + Your free trial ends on %1$s & automatically converts to a %2$s paid subscription on that day. + Your free trial ends on %1$s & will not convert to a paid subscription. + \ No newline at end of file diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 07e9311a07f3..5da2bf7c52d7 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -1274,6 +1274,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { expiresOrRenewsAt = 1234, platform = "android", status = "AUTO_RENEWABLE", + activeOffers = listOf(), ) } @@ -1355,6 +1356,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { expiresOrRenewsAt = 1234, platform = "android", status = status, + activeOffers = listOf(), ), ) } @@ -1368,6 +1370,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { expiresOrRenewsAt = 1234, platform = "android", status = status, + activeOffers = listOf(), ), ) } @@ -1564,6 +1567,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { status = "Auto-Renewable", startedAt = 1000000L, expiresOrRenewsAt = 1000000L, + activeOffers = listOf(), ), ), ) From 8458f0191cc44fb9127bb8677b3b4c62e02e0683 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Fri, 14 Feb 2025 00:45:51 +0000 Subject: [PATCH 07/16] Add new offer-status endpoint --- .../subscriptions/impl/SubscriptionsManager.kt | 15 +++++++++++++++ .../impl/services/SubscriptionsService.kt | 8 ++++++++ .../impl/ui/SubscriptionWebViewViewModel.kt | 11 +++++------ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 2640cb0e1a17..d8861920b200 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -215,6 +215,13 @@ interface SubscriptionsManager { suspend fun getPortalUrl(): String? suspend fun canSupportEncryption(): Boolean + + /** + * Checks whether the user has previously used a trial. + * + * @return [Boolean] indicating if the user has had a trial before. + */ + suspend fun hadTrial(): Boolean } @SingleInstanceIn(AppScope::class) @@ -328,6 +335,14 @@ class RealSubscriptionsManager @Inject constructor( override suspend fun canSupportEncryption(): Boolean = authRepository.canSupportEncryption() + override suspend fun hadTrial(): Boolean { + return try { + return subscriptionsService.offerStatus().hadTrial + } catch (e: Exception) { + false + } + } + override suspend fun getAccount(): Account? = authRepository.getAccount() override suspend fun getPortalUrl(): String? { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt index 890843283425..6a068b598226 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt @@ -35,6 +35,10 @@ interface SubscriptionsService { @GET("https://subscriptions.duckduckgo.com/api/checkout/portal") suspend fun portal(): PortalResponse + @AuthRequired + @GET("https://subscriptions.duckduckgo.com/api/v1/offer-status") + suspend fun offerStatus(): OfferStatusResponse + @AuthRequired @POST("https://subscriptions.duckduckgo.com/api/purchase/confirm/google") suspend fun confirm( @@ -107,3 +111,7 @@ data class FeedbackResponse( data class FeaturesResponse( val features: List, ) + +data class OfferStatusResponse( + val hadTrial: Boolean, +) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index 5da899ea2d0f..20fc10a6a096 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -288,11 +288,10 @@ class SubscriptionWebViewViewModel @Inject constructor( } private fun isFreeTrialEligible(): Boolean { - val hasUsedFreeTrial: Boolean = true // TODO Noelia check previous purchases - return privacyProFeature.privacyProFreeTrialJan25().isEnabled(Cohorts.TREATMENT) && hasUsedFreeTrial + return privacyProFeature.privacyProFreeTrialJan25().isEnabled(Cohorts.TREATMENT) } - private fun createSubscriptionOptions( + private suspend fun createSubscriptionOptions( monthlyOffer: SubscriptionOffer, yearlyOffer: SubscriptionOffer, ): SubscriptionOptionsJson { @@ -305,7 +304,7 @@ class SubscriptionWebViewViewModel @Inject constructor( ) } - private fun createOptionsJson(offer: SubscriptionOffer, recurrence: String): OptionsJson { + private suspend fun createOptionsJson(offer: SubscriptionOffer, recurrence: String): OptionsJson { val offerDisplayPrice: String = offer.offerId?.let { offer.pricingPhases.getOrNull(1)?.formattedPrice ?: offer.pricingPhases.first().formattedPrice } ?: offer.pricingPhases.first().formattedPrice @@ -317,7 +316,7 @@ class SubscriptionWebViewViewModel @Inject constructor( ) } - private fun getOfferJson(offer: SubscriptionOffer): OfferJson? { + private suspend fun getOfferJson(offer: SubscriptionOffer): OfferJson? { return offer.offerId?.let { val offerType = when (offer.offerId) { MONTHLY_FREE_TRIAL_OFFER_US, YEARLY_FREE_TRIAL_OFFER_US -> OfferType.FREE_TRIAL @@ -328,7 +327,7 @@ class SubscriptionWebViewViewModel @Inject constructor( type = offerType.type, id = it, durationInDays = offer.pricingPhases.first().getBillingPeriodInDays(), - isUserEligible = true, // TODO Noelia: Need to check if they already had a free trial before to return false + isUserEligible = !subscriptionsManager.hadTrial(), ) } } From 2c04b976d77efa02410cfe3719c1569178797b2f Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Mon, 17 Feb 2025 18:23:59 +0000 Subject: [PATCH 08/16] Map new offerId parameter when selecting an offer in the paywall --- .../impl/SubscriptionsManager.kt | 3 +- .../impl/billing/PlayBillingManager.kt | 2 +- .../impl/ui/SubscriptionWebViewViewModel.kt | 12 ++++--- .../impl/ui/SubscriptionsWebViewActivity.kt | 6 ++-- .../impl/RealSubscriptionsManagerTest.kt | 34 +++++++++---------- 5 files changed, 31 insertions(+), 26 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index d8861920b200..87ee8cd45128 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -111,7 +111,7 @@ interface SubscriptionsManager { suspend fun purchase( activity: Activity, planId: String, - offerId: String? = null, + offerId: String?, ) /** @@ -840,6 +840,7 @@ class RealSubscriptionsManager @Inject constructor( activity = activity, planId = planId, externalId = authRepository.getAccount()!!.externalId, + offerId = offerId, ) } catch (e: Exception) { val error = extractError(e) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt index 97200bcfc110..a5ba9c729468 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt @@ -75,7 +75,7 @@ interface PlayBillingManager { activity: Activity, planId: String, externalId: String, - offerId: String? = null, + offerId: String?, ) } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index 20fc10a6a096..7f80bc3e1c32 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -221,18 +221,19 @@ class SubscriptionWebViewViewModel @Inject constructor( viewModelScope.launch(dispatcherProvider.io()) { val id = runCatching { data?.getString("id") }.getOrNull() + val offerId = runCatching { data?.getString("offerId") }.getOrNull() if (id.isNullOrBlank()) { pixelSender.reportPurchaseFailureOther() _currentPurchaseViewState.emit(currentPurchaseViewState.value.copy(purchaseState = Failure)) } else { - command.send(SubscriptionSelected(id)) + command.send(SubscriptionSelected(id, offerId)) } } } - fun purchaseSubscription(activity: Activity, planId: String) { + fun purchaseSubscription(activity: Activity, planId: String, offerId: String?) { viewModelScope.launch(dispatcherProvider.io()) { - subscriptionsManager.purchase(activity, planId) + subscriptionsManager.purchase(activity, planId, offerId) } } @@ -397,7 +398,10 @@ class SubscriptionWebViewViewModel @Inject constructor( data object BackToSettingsActivateSuccess : Command() data class SendJsEvent(val event: SubscriptionEventData) : Command() data class SendResponseToJs(val data: JsCallbackData) : Command() - data class SubscriptionSelected(val id: String) : Command() + data class SubscriptionSelected( + val id: String, + val offerId: String?, + ) : Command() data object RestoreSubscription : Command() data object GoToITR : Command() data object GoToPIR : Command() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt index d52da434d493..230d54e59081 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt @@ -417,7 +417,7 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD is BackToSettings, BackToSettingsActivateSuccess -> backToSettings() is SendJsEvent -> sendJsEvent(command.event) is SendResponseToJs -> sendResponseToJs(command.data) - is SubscriptionSelected -> selectSubscription(command.id) + is SubscriptionSelected -> selectSubscription(command.id, command.offerId) is RestoreSubscription -> restoreSubscription() is GoToITR -> goToITR() is GoToPIR -> goToPIR() @@ -517,8 +517,8 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD .show() } - private fun selectSubscription(id: String) { - viewModel.purchaseSubscription(this, id) + private fun selectSubscription(id: String, offerId: String?) { + viewModel.purchaseSubscription(this, id, offerId) } private fun sendResponseToJs(data: JsCallbackData) { diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 5da2bf7c52d7..80d720ebdf35 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -316,7 +316,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsNotSignedIn() givenCreateAccountSucceeds() - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) if (authApiV2Enabled) { verify(authClient).authorize(any()) @@ -334,7 +334,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { whenever(emailManager.getToken()).thenReturn("emailToken") givenUserIsNotSignedIn() - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) verify(authService).createAccount("Bearer emailToken") } @@ -345,7 +345,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenCreateAccountFails() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Failure) cancelAndConsumeRemainingEvents() @@ -359,7 +359,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenValidateTokenSucceedsNoEntitlements() givenAccessTokenSucceeds() - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) verify(playBillingManager).launchBillingFlow(any(), any(), externalId = eq("1234"), isNull()) } @@ -372,7 +372,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenSubscriptionSucceedsWithoutEntitlements(status = "Expired") givenAccessTokenSucceeds() - subscriptionsManager.purchase(mock(), "") + subscriptionsManager.purchase(mock(), "", null) verify(playBillingManager).launchBillingFlow(any(), any(), externalId = eq("1234"), isNull()) } @@ -386,7 +386,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenAccessTokenSucceeds() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) verify(playBillingManager, never()).launchBillingFlow(any(), any(), any(), isNull()) assertTrue(awaitItem() is CurrentPurchase.Recovered) @@ -402,7 +402,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenStoreLoginFails() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Failure) cancelAndConsumeRemainingEvents() @@ -415,7 +415,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsSignedIn() - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) verify(authService).validateToken(any()) } @@ -426,7 +426,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenSubscriptionSucceedsWithoutEntitlements(status = "Expired") subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) verify(playBillingManager).launchBillingFlow(any(), any(), externalId = eq("1234"), isNull()) assertTrue(awaitItem() is CurrentPurchase.PreFlowFinished) @@ -440,7 +440,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenCreateAccountFails() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Failure) cancelAndConsumeRemainingEvents() @@ -452,7 +452,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsSignedIn() givenValidateTokenFails("failure") - subscriptionsManager.purchase(mock(), "") + subscriptionsManager.purchase(mock(), "", null) verify(authService, never()).createAccount(any()) } @@ -464,7 +464,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenSubscriptionSucceedsWithoutEntitlements() givenAccessTokenSucceeds() - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) if (authApiV2Enabled) { assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) @@ -486,7 +486,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenSubscriptionSucceedsWithoutEntitlements() givenAccessTokenSucceeds() - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) subscriptionsManager.isSignedIn.test { assertTrue(awaitItem()) if (authApiV2Enabled) { @@ -509,7 +509,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsSignedIn() givenSubscriptionFails(httpResponseCode = 400) - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) verify(playBillingManager).launchBillingFlow(any(), any(), any(), isNull()) } @@ -519,7 +519,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsSignedIn() givenSubscriptionFails(httpResponseCode = 404) - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) verify(playBillingManager).launchBillingFlow(any(), any(), any(), isNull()) } @@ -1100,7 +1100,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenAccessTokenSucceeds() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Recovered) @@ -1137,7 +1137,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenCreateAccountFails() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "") + subscriptionsManager.purchase(mock(), planId = "", offerId = null) assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Failure) From 788064626a1a5396baaa86c7b56dbf46889120c0 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Mon, 24 Feb 2025 23:47:37 +0000 Subject: [PATCH 09/16] Added pixel metrics calls --- .../impl/SubscriptionsManager.kt | 10 ++++++++++ .../impl/ui/SubscriptionWebViewViewModel.kt | 19 +++++++++++++++++++ .../impl/ui/SubscriptionsWebViewActivity.kt | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 87ee8cd45128..01ad464d56cf 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -56,6 +56,9 @@ import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState import com.duckduckgo.subscriptions.impl.billing.RetryPolicy import com.duckduckgo.subscriptions.impl.billing.retry +import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin +import com.duckduckgo.subscriptions.impl.freetrial.onSubscriptionStartedMonthly +import com.duckduckgo.subscriptions.impl.freetrial.onSubscriptionStartedYearly import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AccessToken import com.duckduckgo.subscriptions.impl.repository.Account @@ -242,6 +245,7 @@ class RealSubscriptionsManager @Inject constructor( private val pkceGenerator: PkceGenerator, private val timeProvider: CurrentTimeProvider, private val backgroundTokenRefresh: BackgroundTokenRefresh, + private val freeTrialPrivacyProPixelsPlugin: Lazy, ) : SubscriptionsManager { private val adapter = Moshi.Builder().build().adapter(ResponseError::class.java) @@ -461,6 +465,12 @@ class RealSubscriptionsManager @Inject constructor( } if (subscription.isActive()) { + // Free Trial experiment metrics + if (confirmationResponse.subscription.productId.contains("monthly-renews-us")) { + freeTrialPrivacyProPixelsPlugin.get().onSubscriptionStartedMonthly() + } else if (confirmationResponse.subscription.productId.contains("yearly-renews-us")) { + freeTrialPrivacyProPixelsPlugin.get().onSubscriptionStartedYearly() + } pixelSender.reportPurchaseSuccess() pixelSender.reportSubscriptionActivated() emitEntitlementsValues() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index 7f80bc3e1c32..098b97b8a277 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -50,6 +50,10 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_FREE_TRIA import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin +import com.duckduckgo.subscriptions.impl.freetrial.onPaywallImpression +import com.duckduckgo.subscriptions.impl.freetrial.onStartClickedMonthly +import com.duckduckgo.subscriptions.impl.freetrial.onStartClickedYearly import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.isActive import com.duckduckgo.subscriptions.impl.repository.isExpired @@ -83,6 +87,7 @@ class SubscriptionWebViewViewModel @Inject constructor( private val networkProtectionAccessState: NetworkProtectionAccessState, private val pixelSender: SubscriptionPixelSender, private val privacyProFeature: PrivacyProFeature, + private val freeTrialPrivacyProPixelsPlugin: FreeTrialPrivacyProPixelsPlugin, ) : ViewModel() { private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() @@ -228,6 +233,13 @@ class SubscriptionWebViewViewModel @Inject constructor( } else { command.send(SubscriptionSelected(id, offerId)) } + + // Free Trial experiment metrics + if (id?.contains("monthly-renews-us") == true) { + freeTrialPrivacyProPixelsPlugin.onStartClickedMonthly() + } else if (id?.contains("yearly-renews-us") == true) { + freeTrialPrivacyProPixelsPlugin.onStartClickedYearly() + } } } @@ -353,6 +365,13 @@ class SubscriptionWebViewViewModel @Inject constructor( } } + fun paywallShown() { + pixelSender.reportOfferScreenShown() + viewModelScope.launch { + freeTrialPrivacyProPixelsPlugin.onPaywallImpression() + } + } + data class SubscriptionOptionsJson( val platform: String = PLATFORM, val options: List, diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt index 230d54e59081..f07c20ed77bf 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt @@ -259,7 +259,7 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD }.launchIn(lifecycleScope) if (savedInstanceState == null && params.url == BUY_URL) { - pixelSender.reportOfferScreenShown() + viewModel.paywallShown() } } From 587c1323e65db0ba589c22ed18f71427f1ea7aba Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Tue, 25 Feb 2025 00:59:10 +0000 Subject: [PATCH 10/16] Increase value pixel param every time the paywall is shown --- .../subscriptions/impl/ui/SubscriptionWebViewViewModel.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index 098b97b8a277..33be6f9f0c5d 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -50,6 +50,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_FREE_TRIA import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialExperimentDataStore import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin import com.duckduckgo.subscriptions.impl.freetrial.onPaywallImpression import com.duckduckgo.subscriptions.impl.freetrial.onStartClickedMonthly @@ -88,6 +89,7 @@ class SubscriptionWebViewViewModel @Inject constructor( private val pixelSender: SubscriptionPixelSender, private val privacyProFeature: PrivacyProFeature, private val freeTrialPrivacyProPixelsPlugin: FreeTrialPrivacyProPixelsPlugin, + private val freeTrialExperimentDataStore: FreeTrialExperimentDataStore, ) : ViewModel() { private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() @@ -368,6 +370,7 @@ class SubscriptionWebViewViewModel @Inject constructor( fun paywallShown() { pixelSender.reportOfferScreenShown() viewModelScope.launch { + freeTrialExperimentDataStore.increaseMetricForPaywallImpressions() freeTrialPrivacyProPixelsPlugin.onPaywallImpression() } } From a36c8d7d31f977f8a6dad6686568adacc79df76b Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Tue, 25 Feb 2025 18:33:40 +0000 Subject: [PATCH 11/16] Fix tests --- .../freetrial/FreeTrialExperimentDataStore.kt | 7 ++--- .../impl/RealSubscriptionsManagerTest.kt | 11 ++++++++ .../billing/RealPlayBillingManagerTest.kt | 26 ++++++++++++++++--- .../FreeTrialPrivacyProPixelsPluginTest.kt | 4 ++- .../SubscriptionMessagingInterfaceTest.kt | 1 + .../repository/FakeSubscriptionsDataStore.kt | 1 + ...FPProBillingPeriodMatchingAttributeTest.kt | 1 + ...aysSinceSubscribedMatchingAttributeTest.kt | 8 ++++++ ...UntilExpiryRenewalMatchingAttributeTest.kt | 1 + ...roPurchasePlatformMatchingAttributeTest.kt | 3 +++ ...SubscriptionStatusMatchingAttributeTest.kt | 1 + .../survey/PproSurveyParameterPluginsTest.kt | 1 + .../ui/RestoreSubscriptionViewModelTest.kt | 1 + .../ui/SubscriptionSettingsViewModelTest.kt | 4 +++ .../ui/SubscriptionWebViewViewModelTest.kt | 6 +++++ 15 files changed, 69 insertions(+), 7 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt index 2acac8db9ead..6b7f759143f3 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt @@ -16,10 +16,10 @@ package com.duckduckgo.subscriptions.impl.freetrial -import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.data.store.api.SharedPreferencesProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.PixelDefinition import com.squareup.anvil.annotations.ContributesBinding @@ -45,10 +45,11 @@ interface FreeTrialExperimentDataStore { @ContributesBinding(AppScope::class) class FreeTrialExperimentDataStoreImpl @Inject constructor( - private val context: Context, + private val sharedPreferencesProvider: SharedPreferencesProvider, private val dispatcherProvider: DispatcherProvider, ) : FreeTrialExperimentDataStore { - private val preferences: SharedPreferences by lazy { context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE) } + + private val preferences: SharedPreferences by lazy { sharedPreferencesProvider.getSharedPreferences(FILENAME) } override var paywallImpressions: Int get() = preferences.getInt(KEY_PAYWALL_IMPRESSIONS, 0) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 80d720ebdf35..27ebb48d2d6c 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -38,6 +38,7 @@ import com.duckduckgo.subscriptions.impl.auth2.RefreshTokenClaims import com.duckduckgo.subscriptions.impl.auth2.TokenPair import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState +import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AuthRepository @@ -116,6 +117,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { private val authJwtValidator: AuthJwtValidator = mock() private val timeProvider = FakeTimeProvider() private val backgroundTokenRefresh: BackgroundTokenRefresh = mock() + private val freeTrialPrivacyProPixelsPlugin: FreeTrialPrivacyProPixelsPlugin = mock() private lateinit var subscriptionsManager: RealSubscriptionsManager @Before @@ -138,6 +140,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, + { freeTrialPrivacyProPixelsPlugin }, ) } @@ -558,6 +561,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, + { freeTrialPrivacyProPixelsPlugin }, ) manager.subscriptionStatus.test { @@ -586,6 +590,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, + { freeTrialPrivacyProPixelsPlugin }, ) manager.subscriptionStatus.test { @@ -619,6 +624,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, + { freeTrialPrivacyProPixelsPlugin }, ) manager.currentPurchaseState.test { @@ -666,6 +672,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, + { freeTrialPrivacyProPixelsPlugin }, ) manager.currentPurchaseState.test { @@ -703,6 +710,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, + { freeTrialPrivacyProPixelsPlugin }, ) manager.currentPurchaseState.test { @@ -1002,6 +1010,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, + { freeTrialPrivacyProPixelsPlugin }, ) manager.signOut() verify(mockRepo).setSubscription(null) @@ -1047,6 +1056,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, + { freeTrialPrivacyProPixelsPlugin }, ) manager.subscriptionStatus.test { @@ -1228,6 +1238,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, + { freeTrialPrivacyProPixelsPlugin }, ) assertFalse(subscriptionsManager.canSupportEncryption()) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt index 323efd7f92d1..cd67223e8c3d 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt @@ -105,7 +105,7 @@ class RealPlayBillingManagerTest { subject.purchaseState.test { expectNoEvents() - subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN_US, externalId) + subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN_US, externalId, null) assertEquals(InProgress, awaitItem()) } @@ -126,7 +126,7 @@ class RealPlayBillingManagerTest { subject.purchaseState.test { expectNoEvents() - subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN_US, externalId) + subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN_US, externalId, null) assertEquals(Canceled, awaitItem()) } @@ -175,7 +175,27 @@ class RealPlayBillingManagerTest { subject.purchaseState.test { expectNoEvents() - subject.launchBillingFlow(activity = mock(), planId = offerDetails.basePlanId, externalId) + subject.launchBillingFlow(activity = mock(), planId = offerDetails.basePlanId, externalId, null) + + assertEquals(InProgress, awaitItem()) + } + + billingClientAdapter.verifyLaunchBillingFlowInvoked(productDetails, offerToken = offerDetails.offerToken, externalId) + } + + @Test + fun `when launch billing flow then retrieves ProductDetails for provided plan id and offer id`() = runTest { + processLifecycleOwner.currentState = RESUMED + billingClientAdapter.launchBillingFlowResult = LaunchBillingFlowResult.Success + + val productDetails: ProductDetails = subject.products.single() + val offerDetails = productDetails.subscriptionOfferDetails!!.first() + val externalId = "external_id" + + subject.purchaseState.test { + expectNoEvents() + + subject.launchBillingFlow(activity = mock(), planId = offerDetails.basePlanId, externalId = externalId, offerId = offerDetails.offerId) assertEquals(InProgress, awaitItem()) } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt index 21526a877bb6..066152c5a7b1 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.subscriptions.impl.freetrial +import android.annotation.SuppressLint import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeToggleStore @@ -29,6 +30,7 @@ import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock +@SuppressLint("DenyListedApi") class FreeTrialPrivacyProPixelsPluginTest { @get:Rule @Suppress("unused") @@ -47,7 +49,7 @@ class FreeTrialPrivacyProPixelsPluginTest { featureName = "testFeature", ).build().create(PrivacyProFeature::class.java) - testFeature.privacyProFreeTrialJan25().setRawStoredState(State(enable = true)) + testFeature.privacyProFreeTrialJan25().setRawStoredState(state = State(enable = true)) testee = FreeTrialPrivacyProPixelsPlugin( toggle = testFeature, diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt index f541b5fbc82e..fe8b14cd156d 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt @@ -725,6 +725,7 @@ class SubscriptionMessagingInterfaceTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ), ) } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt index 93cfd3ad6859..6587f975f8f7 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt @@ -47,6 +47,7 @@ class FakeSubscriptionsDataStore( override var status: String? = null override var entitlements: String? = null override var productId: String? = null + override var freeTrialActive: Boolean = false override fun canUseEncryption(): Boolean = supportEncryption override var subscriptionFeatures: String? = null } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProBillingPeriodMatchingAttributeTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProBillingPeriodMatchingAttributeTest.kt index 6ebce71c69e8..1b67ea3a3b8f 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProBillingPeriodMatchingAttributeTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProBillingPeriodMatchingAttributeTest.kt @@ -24,6 +24,7 @@ class RMFPProBillingPeriodMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "Google", + activeOffers = listOf(), ) @Before diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProDaysSinceSubscribedMatchingAttributeTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProDaysSinceSubscribedMatchingAttributeTest.kt index e44fefea2e2d..eab5d35369c4 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProDaysSinceSubscribedMatchingAttributeTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProDaysSinceSubscribedMatchingAttributeTest.kt @@ -85,6 +85,7 @@ class RMFPProDaysSinceSubscribedMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProDaysSinceSubscribedMatchingAttribute()) @@ -114,6 +115,7 @@ class RMFPProDaysSinceSubscribedMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProDaysSinceSubscribedMatchingAttribute(value = 15, max = 13, min = 10)) @@ -133,6 +135,7 @@ class RMFPProDaysSinceSubscribedMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProDaysSinceSubscribedMatchingAttribute(max = 15)) @@ -152,6 +155,7 @@ class RMFPProDaysSinceSubscribedMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProDaysSinceSubscribedMatchingAttribute(max = 15)) @@ -171,6 +175,7 @@ class RMFPProDaysSinceSubscribedMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProDaysSinceSubscribedMatchingAttribute(min = 5)) @@ -190,6 +195,7 @@ class RMFPProDaysSinceSubscribedMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProDaysSinceSubscribedMatchingAttribute(min = 5)) @@ -209,6 +215,7 @@ class RMFPProDaysSinceSubscribedMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProDaysSinceSubscribedMatchingAttribute(min = 5, max = 10)) @@ -228,6 +235,7 @@ class RMFPProDaysSinceSubscribedMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProDaysSinceSubscribedMatchingAttribute(min = 5, max = 10)) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProDaysUntilExpiryRenewalMatchingAttributeTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProDaysUntilExpiryRenewalMatchingAttributeTest.kt index 1bbe3e5a24ee..9245db92ed26 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProDaysUntilExpiryRenewalMatchingAttributeTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProDaysUntilExpiryRenewalMatchingAttributeTest.kt @@ -35,6 +35,7 @@ class RMFPProDaysUntilExpiryRenewalMatchingAttributeTest { expiresOrRenewsAt = DAYS.toMillis(10), status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ) @Before diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProPurchasePlatformMatchingAttributeTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProPurchasePlatformMatchingAttributeTest.kt index 74638e4744b3..0528457a8432 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProPurchasePlatformMatchingAttributeTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProPurchasePlatformMatchingAttributeTest.kt @@ -72,6 +72,7 @@ class RMFPProPurchasePlatformMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "Google", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProPurchasePlatformMatchingAttribute(listOf("google", "ios"))) @@ -91,6 +92,7 @@ class RMFPProPurchasePlatformMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "iOS", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProPurchasePlatformMatchingAttribute(listOf("android"))) @@ -121,6 +123,7 @@ class RMFPProPurchasePlatformMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "", + activeOffers = listOf(), ), ) val result = matcher.evaluate(PProPurchasePlatformMatchingAttribute(listOf("android"))) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriptionStatusMatchingAttributeTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriptionStatusMatchingAttributeTest.kt index 354ce9231fae..0a4eadfef75f 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriptionStatusMatchingAttributeTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/rmf/RMFPProSubscriptionStatusMatchingAttributeTest.kt @@ -30,6 +30,7 @@ class RMFPProSubscriptionStatusMatchingAttributeTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "Google", + activeOffers = listOf(), ) @Before diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt index 5e764fc9e966..36510bb2a1b1 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/survey/PproSurveyParameterPluginsTest.kt @@ -29,6 +29,7 @@ class PproSurveyParameterPluginTest { expiresOrRenewsAt = 1719525600000, // June 27 UTC status = AUTO_RENEWABLE, platform = "android", + activeOffers = listOf(), ) @Before diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt index 9441379d3e0f..03088781b6af 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt @@ -197,6 +197,7 @@ class RestoreSubscriptionViewModelTest { expiresOrRenewsAt = 10000L, status = AUTO_RENEWABLE, platform = "google", + activeOffers = listOf(), ) } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt index 89d7953d7043..8dd2a5196ed5 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt @@ -66,6 +66,7 @@ class SubscriptionSettingsViewModelTest { expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, platform = "android", + activeOffers = listOf(), ), ) @@ -93,6 +94,7 @@ class SubscriptionSettingsViewModelTest { expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, platform = "android", + activeOffers = listOf(), ), ) @@ -120,6 +122,7 @@ class SubscriptionSettingsViewModelTest { expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, platform = "android", + activeOffers = listOf(), ), ) @@ -147,6 +150,7 @@ class SubscriptionSettingsViewModelTest { expiresOrRenewsAt = 1701694623000, status = AUTO_RENEWABLE, platform = "android", + activeOffers = listOf(), ), ) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt index 68c1c2ad47f3..7b849d7a92c6 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt @@ -23,6 +23,8 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialExperimentDataStore +import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.BackToSettings @@ -60,6 +62,8 @@ class SubscriptionWebViewViewModelTest { private val subscriptionsChecker: SubscriptionsChecker = mock() private val pixelSender: SubscriptionPixelSender = mock() private val privacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java, FakeToggleStore()) + private val freeTrialPrivacyProPixelsPlugin: FreeTrialPrivacyProPixelsPlugin = mock() + private val freeTrialExperimentDataStore: FreeTrialExperimentDataStore = mock() private lateinit var viewModel: SubscriptionWebViewViewModel @@ -73,6 +77,8 @@ class SubscriptionWebViewViewModelTest { networkProtectionAccessState, pixelSender, privacyProFeature, + freeTrialPrivacyProPixelsPlugin, + freeTrialExperimentDataStore, ) givenSubscriptionStatus(UNKNOWN) } From edaca1620575e5c0534c72bf324431dc27895703 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Wed, 26 Feb 2025 11:35:05 +0000 Subject: [PATCH 12/16] Fixed free trial expired text on SubscriptionSettingsActivity --- .../subscriptions/impl/ui/SubscriptionSettingsActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt index d9acaaa0e7e8..7ab87fba1865 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt @@ -186,7 +186,7 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { getString(string.freeTrialActiveSubscriptionsData, viewState.date, getString(string.monthly)) viewState.status == AUTO_RENEWABLE && viewState.duration == Yearly -> getString(string.freeTrialActiveSubscriptionsData, viewState.date, getString(string.yearly)) - else -> getString(string.freeTrialCancelledSubscriptionsData) + else -> getString(string.freeTrialCancelledSubscriptionsData, viewState.date) } binding.changePlan.setSecondaryText(subscriptionRenewalDetailsRes) From bdf44f6a5ad4401fc68923d131955409ca7d0590 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Wed, 26 Feb 2025 16:09:31 +0000 Subject: [PATCH 13/16] Replaced prod offer ids --- .../duckduckgo/subscriptions/impl/SubscriptionsConstants.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt index 15fa414a5ae9..36f7d87a4479 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsConstants.kt @@ -34,8 +34,8 @@ object SubscriptionsConstants { const val MONTHLY_PLAN_ROW = "ddg-privacy-pro-monthly-renews-row" // List of offers - const val MONTHLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-sandbox-freetrial-monthly-renews-us" // TODO NOELIA change when experiment added - const val YEARLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-sandbox-freetrial-yearly-renews-us" // TODO NOELIA change when experiment added + const val MONTHLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-freetrial-monthly-renews-us" + const val YEARLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-freetrial-yearly-renews-us" // List of features const val LEGACY_FE_NETP = "vpn" From 31ddc537c8c97bca8874096ef5363e685412bad5 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Wed, 26 Feb 2025 16:56:26 +0000 Subject: [PATCH 14/16] Fixed experiment pixel metrics --- .../freetrial/FreeTrialExperimentDataStore.kt | 16 ++++++++++++++++ .../FreeTrialPrivacyProMetricsPixelPlugin.kt | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt index 6b7f759143f3..31051e30dfaa 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt @@ -41,6 +41,11 @@ interface FreeTrialExperimentDataStore { * Returns the number [Int] of paywall impressions for the given [definition] */ suspend fun getMetricForPixelDefinition(definition: PixelDefinition): Int + + /** + * Increases the count of paywall impressions for the given [definition] + */ + suspend fun increaseMetricForPixelDefinition(definition: PixelDefinition): Int } @ContributesBinding(AppScope::class) @@ -68,6 +73,17 @@ class FreeTrialExperimentDataStoreImpl @Inject constructor( } } + override suspend fun increaseMetricForPixelDefinition(definition: PixelDefinition): Int = + withContext(dispatcherProvider.io()) { + val tag = "$definition" + val count = preferences.getInt(tag, 0) + preferences.edit { + putInt(tag, count + 1) + apply() + } + preferences.getInt(tag, 0) + } + companion object { private const val FILENAME = "com.duckduckgo.subscriptions.freetrial.store" private const val KEY_PAYWALL_IMPRESSIONS = "PAYWALL_IMPRESSIONS" diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt index 99e158d671be..5c588c64999b 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt @@ -111,7 +111,10 @@ class FreeTrialPrivacyProPixelsPlugin @Inject constructor( metricsPixel?.let { metric -> metric.getPixelDefinitions().forEach { definition -> if (definition.isInConversionWindow()) { - freeTrialExperimentDataStore.getMetricForPixelDefinition(definition).takeIf { it < metric.value.toInt() }?.let { + freeTrialExperimentDataStore.getMetricForPixelDefinition(definition).takeIf { + it < freeTrialExperimentDataStore.paywallImpressions + }?.let { + freeTrialExperimentDataStore.increaseMetricForPixelDefinition(definition) pixel.fire(definition.pixelName, definition.params) } } From 8fbcb34b30fcc45c4f8e97336594b07652c9961d Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Thu, 27 Feb 2025 20:49:40 +0000 Subject: [PATCH 15/16] Free Trials: Experiment pixel metrics (#5700) Task/Issue URL: ### Description ### Steps to test this PR _Feature 1_ - [ ] - [ ] ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)| --- .../impl/SubscriptionsManager.kt | 8 ++--- .../freetrial/FreeTrialExperimentDataStore.kt | 29 ++++++++++------ .../FreeTrialPrivacyProMetricsPixelPlugin.kt | 24 ++++++------- .../SubscriptionMessagingInterface.kt | 10 ++++-- .../impl/pixels/SubscriptionPixelSender.kt | 32 +++++++++++++++++ .../impl/ui/SubscriptionWebViewViewModel.kt | 14 +------- .../impl/RealSubscriptionsManagerTest.kt | 11 ------ .../FreeTrialPrivacyProPixelsPluginTest.kt | 26 +++++++++++++- .../SubscriptionMessagingInterfaceTest.kt | 34 +++++++++++++++++-- .../ui/SubscriptionWebViewViewModelTest.kt | 7 ++-- 10 files changed, 132 insertions(+), 63 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 01ad464d56cf..bf76a1d40a2f 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -56,9 +56,6 @@ import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState import com.duckduckgo.subscriptions.impl.billing.RetryPolicy import com.duckduckgo.subscriptions.impl.billing.retry -import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin -import com.duckduckgo.subscriptions.impl.freetrial.onSubscriptionStartedMonthly -import com.duckduckgo.subscriptions.impl.freetrial.onSubscriptionStartedYearly import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AccessToken import com.duckduckgo.subscriptions.impl.repository.Account @@ -245,7 +242,6 @@ class RealSubscriptionsManager @Inject constructor( private val pkceGenerator: PkceGenerator, private val timeProvider: CurrentTimeProvider, private val backgroundTokenRefresh: BackgroundTokenRefresh, - private val freeTrialPrivacyProPixelsPlugin: Lazy, ) : SubscriptionsManager { private val adapter = Moshi.Builder().build().adapter(ResponseError::class.java) @@ -467,9 +463,9 @@ class RealSubscriptionsManager @Inject constructor( if (subscription.isActive()) { // Free Trial experiment metrics if (confirmationResponse.subscription.productId.contains("monthly-renews-us")) { - freeTrialPrivacyProPixelsPlugin.get().onSubscriptionStartedMonthly() + pixelSender.reportFreeTrialOnSubscriptionStartedMonthly() } else if (confirmationResponse.subscription.productId.contains("yearly-renews-us")) { - freeTrialPrivacyProPixelsPlugin.get().onSubscriptionStartedYearly() + pixelSender.reportFreeTrialOnSubscriptionStartedYearly() } pixelSender.reportPurchaseSuccess() pixelSender.reportSubscriptionActivated() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt index 31051e30dfaa..61c90d79619e 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt @@ -38,14 +38,17 @@ interface FreeTrialExperimentDataStore { suspend fun increaseMetricForPaywallImpressions() /** - * Returns the number [Int] of paywall impressions for the given [definition] + * Returns the value [String] for the given pixel [definition] */ - suspend fun getMetricForPixelDefinition(definition: PixelDefinition): Int + suspend fun getMetricForPixelDefinition(definition: PixelDefinition): String? /** * Increases the count of paywall impressions for the given [definition] */ - suspend fun increaseMetricForPixelDefinition(definition: PixelDefinition): Int + suspend fun increaseMetricForPixelDefinition( + definition: PixelDefinition, + value: String, + ): String? } @ContributesBinding(AppScope::class) @@ -54,7 +57,11 @@ class FreeTrialExperimentDataStoreImpl @Inject constructor( private val dispatcherProvider: DispatcherProvider, ) : FreeTrialExperimentDataStore { - private val preferences: SharedPreferences by lazy { sharedPreferencesProvider.getSharedPreferences(FILENAME) } + private val preferences: SharedPreferences by lazy { + sharedPreferencesProvider.getSharedPreferences( + FILENAME, + ) + } override var paywallImpressions: Int get() = preferences.getInt(KEY_PAYWALL_IMPRESSIONS, 0) @@ -66,22 +73,24 @@ class FreeTrialExperimentDataStoreImpl @Inject constructor( } } - override suspend fun getMetricForPixelDefinition(definition: PixelDefinition): Int { + override suspend fun getMetricForPixelDefinition(definition: PixelDefinition): String? { val tag = "$definition" return withContext(dispatcherProvider.io()) { - preferences.getInt(tag, 0) + preferences.getString(tag, null) } } - override suspend fun increaseMetricForPixelDefinition(definition: PixelDefinition): Int = + override suspend fun increaseMetricForPixelDefinition( + definition: PixelDefinition, + value: String, + ): String? = withContext(dispatcherProvider.io()) { val tag = "$definition" - val count = preferences.getInt(tag, 0) preferences.edit { - putInt(tag, count + 1) + putString(tag, value) apply() } - preferences.getInt(tag, 0) + preferences.getString(tag, null) } companion object { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt index 5c588c64999b..494c0ac728f1 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProMetricsPixelPlugin.kt @@ -24,6 +24,7 @@ import com.duckduckgo.feature.toggles.api.MetricsPixelPlugin import com.duckduckgo.feature.toggles.api.PixelDefinition import com.duckduckgo.subscriptions.impl.PrivacyProFeature import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.Lazy import java.time.LocalDate import java.time.ZoneId import java.time.ZonedDateTime @@ -57,7 +58,7 @@ internal suspend fun FreeTrialPrivacyProPixelsPlugin.onSubscriptionStartedYearly @ContributesMultibinding(AppScope::class) class FreeTrialPrivacyProPixelsPlugin @Inject constructor( - private val toggle: PrivacyProFeature, + private val toggle: Lazy, private val freeTrialExperimentDataStore: FreeTrialExperimentDataStore, private val pixel: Pixel, ) : MetricsPixelPlugin { @@ -67,31 +68,31 @@ class FreeTrialPrivacyProPixelsPlugin @Inject constructor( MetricsPixel( metric = "paywallImpressions", value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), - toggle = toggle.privacyProFreeTrialJan25(), + toggle = toggle.get().privacyProFreeTrialJan25(), conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), ), MetricsPixel( metric = "startClickedMonthly", value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), - toggle = toggle.privacyProFreeTrialJan25(), + toggle = toggle.get().privacyProFreeTrialJan25(), conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), ), MetricsPixel( metric = "startClickedYearly", value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), - toggle = toggle.privacyProFreeTrialJan25(), + toggle = toggle.get().privacyProFreeTrialJan25(), conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), ), MetricsPixel( metric = "subscriptionStartedMonthly", value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), - toggle = toggle.privacyProFreeTrialJan25(), + toggle = toggle.get().privacyProFreeTrialJan25(), conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), ), MetricsPixel( metric = "subscriptionStartedYearly", value = getMetricsPixelValue(freeTrialExperimentDataStore.paywallImpressions), - toggle = toggle.privacyProFreeTrialJan25(), + toggle = toggle.get().privacyProFreeTrialJan25(), conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), ), ) @@ -110,13 +111,10 @@ class FreeTrialPrivacyProPixelsPlugin @Inject constructor( internal suspend fun firePixelFor(metricsPixel: MetricsPixel?) { metricsPixel?.let { metric -> metric.getPixelDefinitions().forEach { definition -> - if (definition.isInConversionWindow()) { - freeTrialExperimentDataStore.getMetricForPixelDefinition(definition).takeIf { - it < freeTrialExperimentDataStore.paywallImpressions - }?.let { - freeTrialExperimentDataStore.increaseMetricForPixelDefinition(definition) - pixel.fire(definition.pixelName, definition.params) - } + val hasMetricValueChanged = freeTrialExperimentDataStore.getMetricForPixelDefinition(definition) != metric.value + if (definition.isInConversionWindow() && hasMetricValueChanged) { + freeTrialExperimentDataStore.increaseMetricForPixelDefinition(definition, metric.value) + pixel.fire(definition.pixelName, definition.params) } } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt index 0d8a8b6b4291..4c67a60844af 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt @@ -230,8 +230,14 @@ class SubscriptionMessagingInterface @Inject constructor( ) { appCoroutineScope.launch { when (jsMessage.method) { - "subscriptionsMonthlyPriceClicked" -> pixelSender.reportMonthlyPriceClick() - "subscriptionsYearlyPriceClicked" -> pixelSender.reportYearlyPriceClick() + "subscriptionsMonthlyPriceClicked" -> { + pixelSender.reportMonthlyPriceClick() + pixelSender.reportFreeTrialOnStartClickedMonthly() + } + "subscriptionsYearlyPriceClicked" -> { + pixelSender.reportYearlyPriceClick() + pixelSender.reportFreeTrialOnStartClickedYearly() + } "subscriptionsAddEmailSuccess" -> { pixelSender.reportAddEmailSuccess() subscriptionsManager.tryRefreshAccessToken() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelSender.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelSender.kt index 28f4fc0efd0a..ba2b9d6f4a6c 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelSender.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelSender.kt @@ -20,6 +20,12 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.extensions.toSanitizedLanguageTag import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin +import com.duckduckgo.subscriptions.impl.freetrial.onPaywallImpression +import com.duckduckgo.subscriptions.impl.freetrial.onStartClickedMonthly +import com.duckduckgo.subscriptions.impl.freetrial.onStartClickedYearly +import com.duckduckgo.subscriptions.impl.freetrial.onSubscriptionStartedMonthly +import com.duckduckgo.subscriptions.impl.freetrial.onSubscriptionStartedYearly import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ACTIVATE_SUBSCRIPTION_ENTER_EMAIL_CLICK import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ACTIVATE_SUBSCRIPTION_RESTORE_PURCHASE_CLICK import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.APP_SETTINGS_IDTR_CLICK @@ -106,12 +112,18 @@ interface SubscriptionPixelSender { fun reportAuthV2MigrationFailureOther() fun reportAuthV2TokenValidationError() fun reportAuthV2TokenStoreError() + suspend fun reportFreeTrialExperimentOnPaywallImpression() + suspend fun reportFreeTrialOnStartClickedMonthly() + suspend fun reportFreeTrialOnStartClickedYearly() + suspend fun reportFreeTrialOnSubscriptionStartedMonthly() + suspend fun reportFreeTrialOnSubscriptionStartedYearly() } @ContributesBinding(AppScope::class) class SubscriptionPixelSenderImpl @Inject constructor( private val pixelSender: Pixel, private val appBuildConfig: AppBuildConfig, + private val freeTrialPrivacyProPixelsPlugin: FreeTrialPrivacyProPixelsPlugin, ) : SubscriptionPixelSender { override fun reportSubscriptionActive() = @@ -252,6 +264,26 @@ class SubscriptionPixelSenderImpl @Inject constructor( fire(AUTH_V2_TOKEN_STORE_ERROR) } + override suspend fun reportFreeTrialExperimentOnPaywallImpression() { + freeTrialPrivacyProPixelsPlugin.onPaywallImpression() + } + + override suspend fun reportFreeTrialOnStartClickedMonthly() { + freeTrialPrivacyProPixelsPlugin.onStartClickedMonthly() + } + + override suspend fun reportFreeTrialOnStartClickedYearly() { + freeTrialPrivacyProPixelsPlugin.onStartClickedYearly() + } + + override suspend fun reportFreeTrialOnSubscriptionStartedMonthly() { + freeTrialPrivacyProPixelsPlugin.onSubscriptionStartedMonthly() + } + + override suspend fun reportFreeTrialOnSubscriptionStartedYearly() { + freeTrialPrivacyProPixelsPlugin.onSubscriptionStartedYearly() + } + private fun fire(pixel: SubscriptionPixel, params: Map = emptyMap()) { pixel.getPixelNames().forEach { (pixelType, pixelName) -> pixelSender.fire(pixelName = pixelName, type = pixelType, parameters = params) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index 33be6f9f0c5d..3056c453457e 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -51,10 +51,6 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialExperimentDataStore -import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin -import com.duckduckgo.subscriptions.impl.freetrial.onPaywallImpression -import com.duckduckgo.subscriptions.impl.freetrial.onStartClickedMonthly -import com.duckduckgo.subscriptions.impl.freetrial.onStartClickedYearly import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.isActive import com.duckduckgo.subscriptions.impl.repository.isExpired @@ -88,7 +84,6 @@ class SubscriptionWebViewViewModel @Inject constructor( private val networkProtectionAccessState: NetworkProtectionAccessState, private val pixelSender: SubscriptionPixelSender, private val privacyProFeature: PrivacyProFeature, - private val freeTrialPrivacyProPixelsPlugin: FreeTrialPrivacyProPixelsPlugin, private val freeTrialExperimentDataStore: FreeTrialExperimentDataStore, ) : ViewModel() { @@ -235,13 +230,6 @@ class SubscriptionWebViewViewModel @Inject constructor( } else { command.send(SubscriptionSelected(id, offerId)) } - - // Free Trial experiment metrics - if (id?.contains("monthly-renews-us") == true) { - freeTrialPrivacyProPixelsPlugin.onStartClickedMonthly() - } else if (id?.contains("yearly-renews-us") == true) { - freeTrialPrivacyProPixelsPlugin.onStartClickedYearly() - } } } @@ -299,6 +287,7 @@ class SubscriptionWebViewViewModel @Inject constructor( } sendOptionJson(subscriptionOptions) + pixelSender.reportFreeTrialExperimentOnPaywallImpression() // move to paywallShown() if needed after experiment } } @@ -371,7 +360,6 @@ class SubscriptionWebViewViewModel @Inject constructor( pixelSender.reportOfferScreenShown() viewModelScope.launch { freeTrialExperimentDataStore.increaseMetricForPaywallImpressions() - freeTrialPrivacyProPixelsPlugin.onPaywallImpression() } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 27ebb48d2d6c..80d720ebdf35 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -38,7 +38,6 @@ import com.duckduckgo.subscriptions.impl.auth2.RefreshTokenClaims import com.duckduckgo.subscriptions.impl.auth2.TokenPair import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState -import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin import com.duckduckgo.subscriptions.impl.model.Entitlement import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AuthRepository @@ -117,7 +116,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { private val authJwtValidator: AuthJwtValidator = mock() private val timeProvider = FakeTimeProvider() private val backgroundTokenRefresh: BackgroundTokenRefresh = mock() - private val freeTrialPrivacyProPixelsPlugin: FreeTrialPrivacyProPixelsPlugin = mock() private lateinit var subscriptionsManager: RealSubscriptionsManager @Before @@ -140,7 +138,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, - { freeTrialPrivacyProPixelsPlugin }, ) } @@ -561,7 +558,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, - { freeTrialPrivacyProPixelsPlugin }, ) manager.subscriptionStatus.test { @@ -590,7 +586,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, - { freeTrialPrivacyProPixelsPlugin }, ) manager.subscriptionStatus.test { @@ -624,7 +619,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, - { freeTrialPrivacyProPixelsPlugin }, ) manager.currentPurchaseState.test { @@ -672,7 +666,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, - { freeTrialPrivacyProPixelsPlugin }, ) manager.currentPurchaseState.test { @@ -710,7 +703,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, - { freeTrialPrivacyProPixelsPlugin }, ) manager.currentPurchaseState.test { @@ -1010,7 +1002,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, - { freeTrialPrivacyProPixelsPlugin }, ) manager.signOut() verify(mockRepo).setSubscription(null) @@ -1056,7 +1047,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, - { freeTrialPrivacyProPixelsPlugin }, ) manager.subscriptionStatus.test { @@ -1238,7 +1228,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { pkceGenerator, timeProvider, backgroundTokenRefresh, - { freeTrialPrivacyProPixelsPlugin }, ) assertFalse(subscriptionsManager.canSupportEncryption()) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt index 066152c5a7b1..4b798b046d18 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialPrivacyProPixelsPluginTest.kt @@ -19,8 +19,11 @@ package com.duckduckgo.subscriptions.impl.freetrial import android.annotation.SuppressLint import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.ConversionWindow import com.duckduckgo.feature.toggles.api.FakeToggleStore import com.duckduckgo.feature.toggles.api.FeatureToggles +import com.duckduckgo.feature.toggles.api.MetricsPixel +import com.duckduckgo.feature.toggles.api.PixelDefinition import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.subscriptions.impl.PrivacyProFeature import kotlinx.coroutines.test.runTest @@ -29,6 +32,8 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever @SuppressLint("DenyListedApi") class FreeTrialPrivacyProPixelsPluginTest { @@ -52,7 +57,7 @@ class FreeTrialPrivacyProPixelsPluginTest { testFeature.privacyProFreeTrialJan25().setRawStoredState(state = State(enable = true)) testee = FreeTrialPrivacyProPixelsPlugin( - toggle = testFeature, + toggle = { testFeature }, freeTrialExperimentDataStore = mockFreeTrialExperimentDataStore, pixel = mockPixel, ) @@ -127,4 +132,23 @@ class FreeTrialPrivacyProPixelsPluginTest { assertEquals(value, "51+") } + + @Test + fun givenMetricValueAlreadyFiredWhenFirePixelRequestedThenDoNotFireAgain() = runTest { + val mockPixelDefinition: PixelDefinition = mock() + whenever(mockFreeTrialExperimentDataStore.getMetricForPixelDefinition(mockPixelDefinition)).thenReturn("6-10") + + testee.firePixelFor(getMetricPixel("6-10")) + verifyNoInteractions(mockPixel) + verifyNoInteractions(mockFreeTrialExperimentDataStore) + } + + private fun getMetricPixel(value: String): MetricsPixel { + return MetricsPixel( + metric = "metric", + value = value, + toggle = testFeature.privacyProFreeTrialJan25(), + conversionWindow = listOf(ConversionWindow(lowerWindow = 0, upperWindow = 3)), + ) + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt index fe8b14cd156d..098c80656436 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt @@ -15,7 +15,9 @@ import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.Subscription import kotlinx.coroutines.test.runTest import org.json.JSONObject -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -567,7 +569,7 @@ class SubscriptionMessagingInterfaceTest { messagingInterface.process(message, "duckduckgo-android-messaging-secret") verify(pixelSender).reportMonthlyPriceClick() - verifyNoMoreInteractions(pixelSender) + // verifyNoMoreInteractions(pixelSender) Add it back when Free Trials experiment is removed } @Test @@ -581,7 +583,33 @@ class SubscriptionMessagingInterfaceTest { messagingInterface.process(message, "duckduckgo-android-messaging-secret") verify(pixelSender).reportYearlyPriceClick() - verifyNoMoreInteractions(pixelSender) + // verifyNoMoreInteractions(pixelSender) Add it back when Free Trials experiment is removed + } + + @Test + fun `when process and monthly price clicked then experiment pixel sent`() = runTest { + givenInterfaceIsRegistered() + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsMonthlyPriceClicked","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(pixelSender).reportFreeTrialOnStartClickedMonthly() + } + + @Test + fun `when process and yearly price clicked then experiment pixel sent`() = runTest { + givenInterfaceIsRegistered() + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"subscriptionsYearlyPriceClicked","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + verify(pixelSender).reportFreeTrialOnStartClickedYearly() } @Test diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt index 7b849d7a92c6..6e88a3de5e37 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt @@ -24,7 +24,6 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialExperimentDataStore -import com.duckduckgo.subscriptions.impl.freetrial.FreeTrialPrivacyProPixelsPlugin import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.BackToSettings @@ -40,7 +39,9 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.json.JSONObject -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -62,7 +63,6 @@ class SubscriptionWebViewViewModelTest { private val subscriptionsChecker: SubscriptionsChecker = mock() private val pixelSender: SubscriptionPixelSender = mock() private val privacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java, FakeToggleStore()) - private val freeTrialPrivacyProPixelsPlugin: FreeTrialPrivacyProPixelsPlugin = mock() private val freeTrialExperimentDataStore: FreeTrialExperimentDataStore = mock() private lateinit var viewModel: SubscriptionWebViewViewModel @@ -77,7 +77,6 @@ class SubscriptionWebViewViewModelTest { networkProtectionAccessState, pixelSender, privacyProFeature, - freeTrialPrivacyProPixelsPlugin, freeTrialExperimentDataStore, ) givenSubscriptionStatus(UNKNOWN) From d9bd1d09c818a7e5423d965659998cb985b81b26 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Fri, 28 Feb 2025 19:25:38 +0000 Subject: [PATCH 16/16] Update subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt Co-authored-by: Lukasz Macionczyk --- .../impl/freetrial/FreeTrialExperimentDataStore.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt index 61c90d79619e..b8ba4edd6764 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/freetrial/FreeTrialExperimentDataStore.kt @@ -86,9 +86,8 @@ class FreeTrialExperimentDataStoreImpl @Inject constructor( ): String? = withContext(dispatcherProvider.io()) { val tag = "$definition" - preferences.edit { + preferences.edit(commit = true) { putString(tag, value) - apply() } preferences.getString(tag, null) }