Skip to content

Subscription copy changes for duck.ai and plans #6383

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@
android:layout_height="wrap_content"
app:indicatorStatus="on"
app:leadingIcon="@drawable/ic_ai_chat_color_24"
app:pillIcon="newPill"
app:primaryText="@string/duck_ai_paid_settings_title" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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.api

interface SubscriptionRebrandingFeatureToggle {
/**
* This method is safe to call from the main thread.
* @return true if the subscription rebranding feature is enabled, false otherwise
*/
fun isSubscriptionRebrandingEnabled(): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ interface PrivacyProFeature {
*/
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun duckAISubscriptionMessaging(): Toggle

@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun subscriptionRebranding(): Toggle
}

@ContributesBinding(AppScope::class)
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Why are you pre-fetching the value and caching it, instead of simply checking if the toggle is enabled in the API implementation? Is this a pattern we should follow if a view depends on it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing Toggle.isEnabled produces an IO operation which should happen in a IO thread. In some places I can't perform that operation in the background, so I'm using this main-thread safe method.

Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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

import androidx.lifecycle.LifecycleOwner
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin
import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import logcat.logcat

@ContributesBinding(
scope = AppScope::class,
boundType = SubscriptionRebrandingFeatureToggle::class,
)
@ContributesMultibinding(
scope = AppScope::class,
boundType = PrivacyConfigCallbackPlugin::class,
)
@ContributesMultibinding(
scope = AppScope::class,
boundType = MainProcessLifecycleObserver::class,
)
@SingleInstanceIn(AppScope::class)
class SubscriptionRebrandingFeatureToggleImpl @Inject constructor(
private val privacyProFeature: PrivacyProFeature,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
) : SubscriptionRebrandingFeatureToggle, PrivacyConfigCallbackPlugin, MainProcessLifecycleObserver {

private var cachedValue: Boolean = false

override fun isSubscriptionRebrandingEnabled(): Boolean {
return cachedValue
}

override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
logcat { "SubscriptionRebrandingFeatureToggle: App created, prefetching feature flag" }
prefetchFeatureFlag()
}

override fun onPrivacyConfigDownloaded() {
logcat { "SubscriptionRebrandingFeatureToggle: Privacy config downloaded, refreshing feature flag" }
prefetchFeatureFlag()
}

private fun prefetchFeatureFlag() {
appCoroutineScope.launch(dispatcherProvider.io()) {
val isEnabled = privacyProFeature.subscriptionRebranding().isEnabled()
cachedValue = isEnabled
logcat { "SubscriptionRebrandingFeatureToggle: Feature flag cached, value = $isEnabled" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ import android.view.View
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle
import com.duckduckgo.subscriptions.impl.R
import com.duckduckgo.subscriptions.impl.databinding.ContentFeedbackGeneralBinding
import javax.inject.Inject

@InjectWith(FragmentScope::class)
class SubscriptionFeedbackGeneralFragment : SubscriptionFeedbackFragment(R.layout.content_feedback_general) {

@Inject
lateinit var subscriptionRebrandingFeatureToggle: SubscriptionRebrandingFeatureToggle

private val binding: ContentFeedbackGeneralBinding by viewBinding()

override fun onViewCreated(
Expand All @@ -39,6 +45,11 @@ class SubscriptionFeedbackGeneralFragment : SubscriptionFeedbackFragment(R.layou
listener.onBrowserFeedbackClicked()
}

if (subscriptionRebrandingFeatureToggle.isSubscriptionRebrandingEnabled()) {
binding.pproFeedback.setPrimaryText(getString(R.string.feedbackGeneralSubscription))
} else {
binding.pproFeedback.setPrimaryText(getString(R.string.feedbackGeneralPpro))
}
binding.pproFeedback.setOnClickListener {
listener.onPproFeedbackClicked()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.duckduckgo.anvil.annotations.PriorityKey
import com.duckduckgo.common.ui.view.listitem.SectionHeaderListItem
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.settings.api.ProSettingsPlugin
import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle
import com.duckduckgo.subscriptions.impl.R
import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingView
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingView
Expand All @@ -31,10 +32,16 @@ import javax.inject.Inject

@ContributesMultibinding(ActivityScope::class)
@PriorityKey(100)
class ProSettingsTitle @Inject constructor() : ProSettingsPlugin {
class ProSettingsTitle @Inject constructor(
private val subscriptionRebrandingFeatureToggle: SubscriptionRebrandingFeatureToggle,
) : ProSettingsPlugin {
override fun getView(context: Context): View {
return SectionHeaderListItem(context).apply {
primaryText = context.getString(R.string.privacyPro)
if (subscriptionRebrandingFeatureToggle.isSubscriptionRebrandingEnabled()) {
primaryText = context.getString(R.string.subscriptionSettingSectionTitle)
} else {
primaryText = context.getString(R.string.privacyPro)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,13 @@ class ProSettingView @JvmOverloads constructor(
}
else -> {
with(binding) {
subscriptionBuy.setPrimaryText(context.getString(R.string.subscriptionSettingSubscribe))
if (viewState.duckAiEnabled) {
subscriptionBuy.setPrimaryText(context.getString(R.string.subscriptionSettingSubscribeSecure))
} else {
subscriptionBuy.setPrimaryText(context.getString(R.string.subscriptionSettingSubscribe))
}
subscriptionBuy.setSecondaryText(getSubscriptionSecondaryText(viewState))
subscriptionGet.setText(
when (viewState.freeTrialEligible) {
true -> R.string.subscriptionSettingTryFreeTrial
false -> R.string.subscriptionSettingGet
},
)
subscriptionGet.setText(getActionButtonText(viewState))

subscriptionBuyContainer.isVisible = true
subscriptionRestoreContainer.isVisible = true
Expand All @@ -185,6 +184,24 @@ class ProSettingView @JvmOverloads constructor(
}
}

private fun getActionButtonText(viewState: ViewState) = when (viewState.freeTrialEligible) {
true -> {
if (viewState.rebrandingEnabled) {
R.string.subscriptionSettingTryFreeTrialRebranding
} else {
R.string.subscriptionSettingTryFreeTrial
}
}

false -> {
if (viewState.rebrandingEnabled) {
R.string.subscriptionSettingGetRebranding
} else {
R.string.subscriptionSettingGet
}
}
}

private fun getSubscriptionSecondaryText(viewState: ViewState) = if (viewState.duckAiPlusAvailable) {
when (viewState.region) {
ROW -> context.getString(R.string.subscriptionSettingSubscribeWithDuckAiSubtitleRow)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.subscriptions.api.Product.DuckAiPlus
import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle
import com.duckduckgo.subscriptions.api.SubscriptionStatus
import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
import com.duckduckgo.subscriptions.impl.PrivacyProFeature
Expand Down Expand Up @@ -54,6 +55,7 @@ import kotlinx.coroutines.withContext
@SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle
@ContributesViewModel(ViewScope::class)
class ProSettingViewModel @Inject constructor(
private val subscriptionRebrandingFeatureToggle: SubscriptionRebrandingFeatureToggle,
private val subscriptionsManager: SubscriptionsManager,
private val pixelSender: SubscriptionPixelSender,
private val privacyProFeature: PrivacyProFeature,
Expand All @@ -71,6 +73,8 @@ class ProSettingViewModel @Inject constructor(
data class ViewState(
val status: SubscriptionStatus = UNKNOWN,
val region: SubscriptionRegion? = null,
val duckAiEnabled: Boolean = false,
val rebrandingEnabled: Boolean = false,
val duckAiPlusAvailable: Boolean = false,
val freeTrialEligible: Boolean = false,
) {
Expand Down Expand Up @@ -111,11 +115,14 @@ class ProSettingViewModel @Inject constructor(
val duckAiAvailable = duckAiEnabled && offer?.features?.any { feature ->
feature == DuckAiPlus.value
} ?: false
val rebrandingEnabled = subscriptionRebrandingFeatureToggle.isSubscriptionRebrandingEnabled()

_viewState.emit(
viewState.value.copy(
status = subscriptionStatus,
region = region,
duckAiEnabled = duckAiEnabled,
rebrandingEnabled = rebrandingEnabled,
duckAiPlusAvailable = duckAiAvailable,
freeTrialEligible = subscriptionsManager.isFreeTrialEligible(),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@
<com.duckduckgo.common.ui.view.listitem.OneLineListItem
android:id="@+id/pproFeedback"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryText="@string/feedbackGeneralPpro" />
android:layout_height="wrap_content"/>

</LinearLayout>
</androidx.core.widget.NestedScrollView>
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@
-->

<resources>
<string name="subscriptionSettingSubscribeWithDuckAiSubtitle">Includes our VPN, Duck.ai Plus, Personal Information Removal, and Identity Theft Restoration.</string>
<string name="subscriptionSettingSubscribeWithDuckAiSubtitleRow">Includes our VPN, Duck.ai Plus and Identity Theft Restoration.</string>
<string name="subscriptionSettingSubscribeWithDuckAiSubtitle">Subscribers get our VPN, advanced AI models in Duck.ai, Personal Information Removal, and Identity Theft Restoration.</string>
<string name="subscriptionSettingSubscribeWithDuckAiSubtitleRow">Subscribers get our VPN, advanced AI models in Duck.ai, and Identity Theft Restoration.</string>

<string name="feedbackCategoryDuckAi">Duck.ai</string>
<string name="feedbackSubCategoryDuckAiSubscriberModels">Unable to access the subscriber-only Duck.ai models</string>
<string name="feedbackSubCategoryDuckAiLoginThirdPartyBrowser">Can\'t access Duck.ai with my subscription in other browsers</string>
<string name="feedbackSubCategoryDuckAiOther">Other Duck.ai feedback</string>

<string name="subscriptionSettingSectionTitle" translatable="false">DuckDuckGo Subscription</string>
<string name="subscriptionSettingSubscribeSecure">Secure your Wi-Fi, and chat privately with advanced AI models</string>

<string name="subscriptionSettingGetRebranding">Subscribe to DuckDuckGo</string>
<string name="subscriptionSettingTryFreeTrialRebranding">Try Free</string>
<string name="feedbackGeneralSubscription">Subscription</string>
</resources>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'd need to remove this change and do it in another PR so we trigger translations job.

Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@
<!--Settings Subscriptions-->
<string name="subscriptionSetting">Subscription Settings</string>
<string name="subscriptionSettingSubscribe">Protect your connection and identity with Privacy Pro</string>
<string name="subscriptionSettingSubscribeSubtitle">Includes our VPN, Personal Information Removal, and Identity Theft Restoration.</string>
<string name="subscriptionSettingSubscribeSubtitleRow">Includes our VPN and Identity Theft Restoration.</string>
<string name="subscriptionSettingSubscribeSubtitle">Subscribers get our VPN, Personal Information Removal, and Identity Theft Restoration.</string>
<string name="subscriptionSettingSubscribeSubtitleRow">Subscribers get our VPN and Identity Theft Restoration.</string>
<string name="subscriptionSettingGet">Get Privacy Pro</string>
<string name="subscriptionSettingTryFreeTrial">Try Privacy Pro Free</string>
<string name="subscriptionSettingRestore">I Have a Subscription</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
import com.duckduckgo.feature.toggles.api.Toggle.State
import com.duckduckgo.subscriptions.api.Product
import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle
import com.duckduckgo.subscriptions.api.SubscriptionStatus
import com.duckduckgo.subscriptions.impl.PrivacyProFeature
import com.duckduckgo.subscriptions.impl.SubscriptionOffer
Expand All @@ -32,12 +33,19 @@ class ProSettingViewModelTest {

private val subscriptionsManager: SubscriptionsManager = mock()
private val pixelSender: SubscriptionPixelSender = mock()
private val subscriptionRebrandingFeatureToggle: SubscriptionRebrandingFeatureToggle = mock()
private lateinit var viewModel: ProSettingViewModel
private val privacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java)

@Before
fun before() {
viewModel = ProSettingViewModel(subscriptionsManager, pixelSender, privacyProFeature, coroutineTestRule.testDispatcherProvider)
viewModel = ProSettingViewModel(
subscriptionRebrandingFeatureToggle,
subscriptionsManager,
pixelSender,
privacyProFeature,
coroutineTestRule.testDispatcherProvider,
)
}

@Test
Expand Down Expand Up @@ -147,6 +155,34 @@ class ProSettingViewModelTest {
}
}

@Test
fun whenRebrandingEnabledThenRebrandingEnabledViewStateTrue() = runTest {
whenever(subscriptionRebrandingFeatureToggle.isSubscriptionRebrandingEnabled()).thenReturn(true)
whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.AUTO_RENEWABLE))
whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(emptyList())
whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(true)

viewModel.onCreate(mock())
viewModel.viewState.test {
assertTrue(awaitItem().rebrandingEnabled)
cancelAndConsumeRemainingEvents()
}
}

@Test
fun whenRebrandingDisabledThenRebrandingEnabledViewStateFalse() = runTest {
whenever(subscriptionRebrandingFeatureToggle.isSubscriptionRebrandingEnabled()).thenReturn(false)
whenever(subscriptionsManager.subscriptionStatus).thenReturn(flowOf(SubscriptionStatus.AUTO_RENEWABLE))
whenever(subscriptionsManager.getSubscriptionOffer()).thenReturn(emptyList())
whenever(subscriptionsManager.isFreeTrialEligible()).thenReturn(true)

viewModel.onCreate(mock())
viewModel.viewState.test {
assertFalse(awaitItem().rebrandingEnabled)
cancelAndConsumeRemainingEvents()
}
}

private val subscriptionOffer = SubscriptionOffer(
planId = "test",
offerId = null,
Expand Down
Loading