From 7a4e10b71b95e861448625e239b460be1aaca01c Mon Sep 17 00:00:00 2001 From: nalcalag Date: Fri, 14 Nov 2025 19:44:27 -0500 Subject: [PATCH 1/4] Implementation for black friday settings text --- .../duckduckgo/subscriptions/impl/RealSubscriptions.kt | 3 +++ .../subscriptions/impl/SubscriptionsManager.kt | 9 +++++++++ .../subscriptions/impl/settings/views/ProSettingView.kt | 7 ++++--- .../impl/settings/views/ProSettingViewModel.kt | 2 ++ .../src/main/res/values/donottranslate.xml | 3 +++ 5 files changed, 21 insertions(+), 3 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 f415333829ab..ea8c267888f1 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 @@ -260,6 +260,9 @@ interface PrivacyProFeature { @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.INTERNAL) fun supportsSwitchSubscription(): Toggle + + @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.INTERNAL) + fun blackFridayOffer2025(): Toggle } @ContributesBinding(AppScope::class) 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 ab6bea48259d..14b936f9c679 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 @@ -268,6 +268,11 @@ interface SubscriptionsManager { * @return [SwitchPlanPricingInfo] containing current price, target price, and yearly monthly equivalent, or null if unavailable */ suspend fun getSwitchPlanPricing(isUpgrade: Boolean): SwitchPlanPricingInfo? + + /** + * @return `true` if the Black Friday offer is available, `false` otherwise + */ + suspend fun blackFridayOfferAvailable(): Boolean } @SingleInstanceIn(AppScope::class) @@ -409,6 +414,10 @@ class RealSubscriptionsManager @Inject constructor( return@withContext hasActiveSubscription && !isOnFreeTrial && isSwitchFeatureEnabled } + override suspend fun blackFridayOfferAvailable(): Boolean = withContext(dispatcherProvider.io()) { + return@withContext privacyProFeature.get().blackFridayOffer2025().isEnabled() + } + override suspend fun getSwitchPlanPricing(isUpgrade: Boolean): SwitchPlanPricingInfo? = withContext(dispatcherProvider.io()) { return@withContext try { val currentSubscription = getSubscription() ?: return@withContext null diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt index c18d9721b9ea..0ad5645610d5 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt @@ -187,9 +187,10 @@ class ProSettingView @JvmOverloads constructor( } } - private fun getActionButtonText(viewState: ViewState) = when (viewState.freeTrialEligible) { - true -> R.string.subscriptionSettingTryFreeTrial - false -> R.string.subscriptionSettingGet + private fun getActionButtonText(viewState: ViewState) = when { + viewState.blackFridayOfferAvailable -> R.string.subscriptionSettingBlackFridayOffer + viewState.freeTrialEligible -> R.string.subscriptionSettingTryFreeTrial + else -> R.string.subscriptionSettingGet } private fun getSubscriptionSecondaryText(viewState: ViewState) = if (viewState.duckAiPlusAvailable) { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt index baef7b9838ce..521e36ef6068 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt @@ -73,6 +73,7 @@ class ProSettingViewModel @Inject constructor( val region: SubscriptionRegion? = null, val duckAiPlusAvailable: Boolean = false, val freeTrialEligible: Boolean = false, + val blackFridayOfferAvailable: Boolean = false, ) { enum class SubscriptionRegion { US, ROW } } @@ -118,6 +119,7 @@ class ProSettingViewModel @Inject constructor( region = region, duckAiPlusAvailable = duckAiAvailable, freeTrialEligible = subscriptionsManager.isFreeTrialEligible(), + blackFridayOfferAvailable = subscriptionsManager.blackFridayOfferAvailable(), ), ) } diff --git a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml index 90476d16a39b..89243d8488bc 100644 --- a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml +++ b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml @@ -21,4 +21,7 @@ Personal Information Removal is not available at this moment. Please restart the app and try again. OK + + Save 40% on First Year + From d2f4c158e4b12bfdbedae2013412a0c8f123b792 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Fri, 14 Nov 2025 19:46:04 -0500 Subject: [PATCH 2/4] tests --- .../impl/RealSubscriptionsManagerTest.kt | 55 +++++++++++++++++++ .../settings/views/ProSettingViewModelTest.kt | 33 +++++++++++ 2 files changed, 88 insertions(+) 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 1e0cd38d97ec..8543c9556e6d 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 @@ -2181,6 +2181,61 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { whenever(playBillingManager.products).thenReturn(listOf(productDetails)) } + @Test + fun whenBlackFridayOfferAvailableWithFeatureFlagEnabledThenReturnTrue() = runTest { + givenBlackFridayFeatureFlagEnabled(true) + + val result = subscriptionsManager.blackFridayOfferAvailable() + + assertTrue(result) + } + + @Test + fun whenBlackFridayOfferAvailableWithFeatureFlagDisabledThenReturnFalse() = runTest { + givenBlackFridayFeatureFlagEnabled(false) + + val result = subscriptionsManager.blackFridayOfferAvailable() + + assertFalse(result) + } + + @SuppressLint("DenyListedApi") + private fun givenBlackFridayFeatureFlagEnabled(value: Boolean) { + privacyProFeature.blackFridayOffer2025().setRawStoredState(State(remoteEnableState = value)) + } + + private fun givenUSPlanOffersExist() { + val monthlyOffer: SubscriptionOfferDetails = mock { + on { basePlanId } doReturn MONTHLY_PLAN_US + on { offerId } doReturn null + } + val yearlyOffer: SubscriptionOfferDetails = mock { + on { basePlanId } doReturn YEARLY_PLAN_US + on { offerId } doReturn null + } + val productDetails: ProductDetails = mock { + on { productId } doReturn SubscriptionsConstants.BASIC_SUBSCRIPTION + on { subscriptionOfferDetails } doReturn listOf(monthlyOffer, yearlyOffer) + } + whenever(playBillingManager.products).thenReturn(listOf(productDetails)) + } + + private fun givenROWPlanOffersExist() { + val monthlyOffer: SubscriptionOfferDetails = mock { + on { basePlanId } doReturn MONTHLY_PLAN_ROW + on { offerId } doReturn null + } + val yearlyOffer: SubscriptionOfferDetails = mock { + on { basePlanId } doReturn YEARLY_PLAN_ROW + on { offerId } doReturn null + } + val productDetails: ProductDetails = mock { + on { productId } doReturn SubscriptionsConstants.BASIC_SUBSCRIPTION + on { subscriptionOfferDetails } doReturn listOf(monthlyOffer, yearlyOffer) + } + whenever(playBillingManager.products).thenReturn(listOf(productDetails)) + } + private companion object { @JvmStatic @Parameterized.Parameters(name = "authApiV2Enabled={0}") diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModelTest.kt index 5f9fd0f319ae..2131dacf45ce 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModelTest.kt @@ -81,6 +81,7 @@ class ProSettingViewModelTest { whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.EXPIRED)) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(emptyList()) whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(false) + whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(false) viewModel.onCreate(mock()) viewModel.viewState.test { @@ -104,6 +105,7 @@ class ProSettingViewModelTest { whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.INACTIVE)) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(emptyList()) whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(true) + whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(false) viewModel.onCreate(mock()) viewModel.viewState.test { @@ -118,6 +120,7 @@ class ProSettingViewModelTest { whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.AUTO_RENEWABLE)) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(listOf(subscriptionOffer.copy(features = setOf(Product.DuckAiPlus.value)))) whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(true) + whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(false) viewModel.onCreate(mock()) viewModel.viewState.test { @@ -132,6 +135,7 @@ class ProSettingViewModelTest { whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.AUTO_RENEWABLE)) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(listOf(subscriptionOffer.copy(features = setOf(Product.NetP.value)))) whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(true) + whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(false) viewModel.onCreate(mock()) viewModel.viewState.test { @@ -146,6 +150,7 @@ class ProSettingViewModelTest { whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.AUTO_RENEWABLE)) whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(listOf(subscriptionOffer.copy(features = setOf(Product.DuckAiPlus.value)))) whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(true) + whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(false) viewModel.onCreate(mock()) viewModel.viewState.test { @@ -154,6 +159,34 @@ class ProSettingViewModelTest { } } + @Test + fun whenBlackFridayOfferAvailableThenViewStateBlackFridayOfferAvailableTrue() = runTest { + whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.INACTIVE)) + whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(emptyList()) + whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(false) + whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(true) + + viewModel.onCreate(mock()) + viewModel.viewState.test { + assertTrue(awaitItem().blackFridayOfferAvailable) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun whenBlackFridayOfferNotAvailableThenViewStateBlackFridayOfferAvailableFalse() = runTest { + whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.INACTIVE)) + whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(emptyList()) + whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(false) + whenever(subscriptionsManager.blackFridayOfferAvailable()).thenReturn(false) + + viewModel.onCreate(mock()) + viewModel.viewState.test { + assertFalse(awaitItem().blackFridayOfferAvailable) + cancelAndConsumeRemainingEvents() + } + } + private val subscriptionOffer = SubscriptionOffer( planId = "test", offerId = null, From d3b6848ce253859699683b1972e9360215ef2b01 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Fri, 14 Nov 2025 19:57:42 -0500 Subject: [PATCH 3/4] Moving viewState emit to main thread so there is no extra delays --- .../impl/settings/views/ProSettingViewModel.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt index 521e36ef6068..90092e8ddf37 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt @@ -100,7 +100,7 @@ class ProSettingViewModel @Inject constructor( subscriptionsManager.subscriptionStatus .distinctUntilChanged() .onEach { subscriptionStatus -> - withContext(dispatcherProvider.io()) { + val newViewState = withContext(dispatcherProvider.io()) { val offer = subscriptionsManager.getSubscriptionOffer().firstOrNull() val region = when (offer?.planId) { MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW -> SubscriptionRegion.ROW @@ -113,16 +113,15 @@ class ProSettingViewModel @Inject constructor( feature == DuckAiPlus.value } ?: false - _viewState.emit( - viewState.value.copy( - status = subscriptionStatus, - region = region, - duckAiPlusAvailable = duckAiAvailable, - freeTrialEligible = subscriptionsManager.isFreeTrialEligible(), - blackFridayOfferAvailable = subscriptionsManager.blackFridayOfferAvailable(), - ), + viewState.value.copy( + status = subscriptionStatus, + region = region, + duckAiPlusAvailable = duckAiAvailable, + freeTrialEligible = subscriptionsManager.isFreeTrialEligible(), + blackFridayOfferAvailable = subscriptionsManager.blackFridayOfferAvailable(), ) } + _viewState.emit(newViewState) }.launchIn(viewModelScope) } From 68ab9d8b8c81bc4d5ecb162c953ce9cd4744f092 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Mon, 17 Nov 2025 14:39:08 -0800 Subject: [PATCH 4/4] Clean up tests --- .../impl/RealSubscriptionsManagerTest.kt | 32 ------------------- 1 file changed, 32 deletions(-) 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 8543c9556e6d..1538448891de 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 @@ -2204,38 +2204,6 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { privacyProFeature.blackFridayOffer2025().setRawStoredState(State(remoteEnableState = value)) } - private fun givenUSPlanOffersExist() { - val monthlyOffer: SubscriptionOfferDetails = mock { - on { basePlanId } doReturn MONTHLY_PLAN_US - on { offerId } doReturn null - } - val yearlyOffer: SubscriptionOfferDetails = mock { - on { basePlanId } doReturn YEARLY_PLAN_US - on { offerId } doReturn null - } - val productDetails: ProductDetails = mock { - on { productId } doReturn SubscriptionsConstants.BASIC_SUBSCRIPTION - on { subscriptionOfferDetails } doReturn listOf(monthlyOffer, yearlyOffer) - } - whenever(playBillingManager.products).thenReturn(listOf(productDetails)) - } - - private fun givenROWPlanOffersExist() { - val monthlyOffer: SubscriptionOfferDetails = mock { - on { basePlanId } doReturn MONTHLY_PLAN_ROW - on { offerId } doReturn null - } - val yearlyOffer: SubscriptionOfferDetails = mock { - on { basePlanId } doReturn YEARLY_PLAN_ROW - on { offerId } doReturn null - } - val productDetails: ProductDetails = mock { - on { productId } doReturn SubscriptionsConstants.BASIC_SUBSCRIPTION - on { subscriptionOfferDetails } doReturn listOf(monthlyOffer, yearlyOffer) - } - whenever(playBillingManager.products).thenReturn(listOf(productDetails)) - } - private companion object { @JvmStatic @Parameterized.Parameters(name = "authApiV2Enabled={0}")