diff --git a/duckchat/duckchat-impl/src/main/res/layout/view_duck_ai_settings.xml b/duckchat/duckchat-impl/src/main/res/layout/view_duck_ai_settings.xml index 4eaf155253ce..84f30ae551e4 100644 --- a/duckchat/duckchat-impl/src/main/res/layout/view_duck_ai_settings.xml +++ b/duckchat/duckchat-impl/src/main/res/layout/view_duck_ai_settings.xml @@ -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" /> \ No newline at end of file diff --git a/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/SubscriptionRebrandingFeatureToggle.kt b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/SubscriptionRebrandingFeatureToggle.kt new file mode 100644 index 000000000000..85520b83169e --- /dev/null +++ b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/SubscriptionRebrandingFeatureToggle.kt @@ -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 +} 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 f9426e2b8d54..28df3d5f11a4 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 @@ -206,6 +206,9 @@ interface PrivacyProFeature { */ @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun duckAISubscriptionMessaging(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun subscriptionRebranding(): Toggle } @ContributesBinding(AppScope::class) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionRebrandingFeatureToggle.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionRebrandingFeatureToggle.kt new file mode 100644 index 000000000000..254a04ec9fc2 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionRebrandingFeatureToggle.kt @@ -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" } + } + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackGeneralFragment.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackGeneralFragment.kt index 2b433df83d00..90d51c1f2934 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackGeneralFragment.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackGeneralFragment.kt @@ -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( @@ -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() } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/plugins/SubsSettingsPlugins.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/plugins/SubsSettingsPlugins.kt index 83afe4f3a592..c695d92dcab1 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/plugins/SubsSettingsPlugins.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/plugins/SubsSettingsPlugins.kt @@ -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 @@ -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) + } } } } 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 6c76534bc5cc..51a456ebcb48 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 @@ -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 @@ -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) 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 aae09ea1e63a..5400a7eb7703 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 @@ -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 @@ -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, @@ -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, ) { @@ -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(), ), diff --git a/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_general.xml b/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_general.xml index 6069ea158fbd..3cc3ad525864 100644 --- a/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_general.xml +++ b/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_general.xml @@ -38,8 +38,7 @@ + android:layout_height="wrap_content"/> \ No newline at end of file diff --git a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml index 00a6d30b33ad..21529ea0aecf 100644 --- a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml +++ b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml @@ -15,11 +15,18 @@ --> - Includes our VPN, Duck.ai Plus, Personal Information Removal, and Identity Theft Restoration. - Includes our VPN, Duck.ai Plus and Identity Theft Restoration. + Subscribers get our VPN, advanced AI models in Duck.ai, Personal Information Removal, and Identity Theft Restoration. + Subscribers get our VPN, advanced AI models in Duck.ai, and Identity Theft Restoration. Duck.ai Unable to access the subscriber-only Duck.ai models Can\'t access Duck.ai with my subscription in other browsers Other Duck.ai feedback + + DuckDuckGo Subscription + Secure your Wi-Fi, and chat privately with advanced AI models + + Subscribe to DuckDuckGo + Try Free + Subscription \ No newline at end of file diff --git a/subscriptions/subscriptions-impl/src/main/res/values/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values/strings-subscriptions.xml index fc443b45dd0c..eb04f6d4c342 100644 --- a/subscriptions/subscriptions-impl/src/main/res/values/strings-subscriptions.xml +++ b/subscriptions/subscriptions-impl/src/main/res/values/strings-subscriptions.xml @@ -68,8 +68,8 @@ Subscription Settings Protect your connection and identity with Privacy Pro - Includes our VPN, Personal Information Removal, and Identity Theft Restoration. - Includes our VPN and Identity Theft Restoration. + Subscribers get our VPN, Personal Information Removal, and Identity Theft Restoration. + Subscribers get our VPN and Identity Theft Restoration. Get Privacy Pro Try Privacy Pro Free I Have a Subscription 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 717d5bf478f2..a182acf0d97e 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 @@ -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 @@ -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 @@ -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,