diff --git a/subscriptions/subscriptions-impl/build.gradle b/subscriptions/subscriptions-impl/build.gradle index 65624bc6c426..7328b7b3c78e 100644 --- a/subscriptions/subscriptions-impl/build.gradle +++ b/subscriptions/subscriptions-impl/build.gradle @@ -60,6 +60,7 @@ dependencies { implementation project(':survey-api') implementation project(':vpn-api') implementation project(':content-scope-scripts-api') + implementation project(':duckchat-api') implementation AndroidX.appCompat implementation KotlinX.coroutines.core 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 51330b909234..49b53ba6237d 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 @@ -168,6 +168,11 @@ interface PrivacyProFeature { @Toggle.DefaultValue(DefaultFeatureValue.FALSE) fun privacyProFreeTrial(): Toggle + /** + * Enables/Disables duckAi for subscribers (advanced models) + * This flag is used to hide the feature in the native client and FE. + * It will be used for the feature rollout and kill-switch if necessary. + */ @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) fun duckAiPlus(): Toggle 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 627f8cebf349..45643d392d09 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 @@ -45,6 +45,7 @@ object SubscriptionsConstants { const val ITR = "Identity Theft Restoration" const val ROW_ITR = "Global Identity Theft Restoration" const val PIR = "Data Broker Protection" + const val DUCK_AI = "Duck.ai" // Platform const val PLATFORM = "android" 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 86aba30ca4d0..94fe3d95a5af 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 @@ -368,8 +368,10 @@ class SubscriptionMessagingInterface @Inject constructor( if (privacyProFeature.enableNewSubscriptionMessages().isEnabled().not()) return val authV2Enabled = privacyProFeature.enableSubscriptionFlowsV2().isEnabled() + val duckAiSubscriberModelsEnabled = privacyProFeature.duckAiPlus().isEnabled() val resultJson = JSONObject().apply { put("useSubscriptionsAuthV2", authV2Enabled) + put("usePaidDuckAi", duckAiSubscriberModelsEnabled) } val response = JsRequestResponse.Success( diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixel.kt index 5264bb8313e0..e53067413c4b 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixel.kt @@ -141,6 +141,11 @@ enum class SubscriptionPixel( type = Unique(), includedParameters = setOf(ATB, APP_VERSION), ), + ONBOARDING_DUCK_AI_CLICK( + baseName = "m_privacy-pro_welcome_paid-ai-chat_click", + type = Unique(), + includedParameters = setOf(ATB, APP_VERSION), + ), SUBSCRIPTION_SETTINGS_SHOWN( baseName = "m_privacy-pro_settings_screen_impression", type = Count, 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 f0d902f5b811..2c798975fff9 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 @@ -39,6 +39,7 @@ import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.OFFER_RESTORE_ import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.OFFER_SCREEN_SHOWN import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.OFFER_SUBSCRIBE_CLICK import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ONBOARDING_ADD_DEVICE_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ONBOARDING_DUCK_AI_CLICK import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ONBOARDING_IDTR_CLICK import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ONBOARDING_PIR_CLICK import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ONBOARDING_VPN_CLICK @@ -89,6 +90,7 @@ interface SubscriptionPixelSender { fun reportOnboardingVpnClick() fun reportOnboardingPirClick() fun reportOnboardingIdtrClick() + fun reportOnboardingDuckAiClick() fun reportSubscriptionSettingsShown() fun reportAppSettingsPirClick() fun reportAppSettingsIdtrClick() @@ -197,6 +199,9 @@ class SubscriptionPixelSenderImpl @Inject constructor( override fun reportOnboardingIdtrClick() = fire(ONBOARDING_IDTR_CLICK) + override fun reportOnboardingDuckAiClick() = + fire(ONBOARDING_DUCK_AI_CLICK) + override fun reportSubscriptionSettingsShown() = fire(SUBSCRIPTION_SETTINGS_SHOWN) 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 fe7c32e9df27..2395fe43cbf1 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 @@ -32,6 +32,7 @@ import com.duckduckgo.subscriptions.impl.JSONObjectAdapter import com.duckduckgo.subscriptions.impl.PrivacyProFeature import com.duckduckgo.subscriptions.impl.SubscriptionOffer import com.duckduckgo.subscriptions.impl.SubscriptionsChecker +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.DUCK_AI import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ITR import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_ITR import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_NETP @@ -193,6 +194,7 @@ class SubscriptionWebViewViewModel @Inject constructor( NETP, LEGACY_FE_NETP -> networkProtectionAccessState.getScreenForCurrentState()?.let { GoToNetP(it) } ITR, LEGACY_FE_ITR, ROW_ITR -> GoToITR PIR, LEGACY_FE_PIR -> GoToPIR + DUCK_AI -> GoToDuckAI else -> null } if (hasPurchasedSubscription()) { @@ -200,6 +202,7 @@ class SubscriptionWebViewViewModel @Inject constructor( GoToITR -> pixelSender.reportOnboardingIdtrClick() is GoToNetP -> pixelSender.reportOnboardingVpnClick() GoToPIR -> pixelSender.reportOnboardingPirClick() + GoToDuckAI -> pixelSender.reportOnboardingDuckAiClick() else -> {} // no-op } } @@ -428,6 +431,7 @@ class SubscriptionWebViewViewModel @Inject constructor( data object GoToITR : Command() data object GoToPIR : Command() data class GoToNetP(val activityParams: ActivityParams) : Command() + data object GoToDuckAI : Command() data object Reload : 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 3e017d54588b..7ea6ac7e0886 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 @@ -58,6 +58,7 @@ import com.duckduckgo.downloads.api.DownloadStateListener import com.duckduckgo.downloads.api.DownloadsFileActions import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload +import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging @@ -78,6 +79,7 @@ import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.BackToSettings import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.BackToSettingsActivateSuccess +import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.GoToDuckAI import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.GoToITR import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.GoToNetP import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.GoToPIR @@ -161,6 +163,9 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD @Inject lateinit var pixelSender: SubscriptionPixelSender + @Inject + lateinit var duckChat: DuckChat + private val viewModel: SubscriptionWebViewViewModel by bindViewModel() private val binding: ActivitySubscriptionsWebviewBinding by viewBinding() @@ -422,6 +427,7 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD is GoToITR -> goToITR() is GoToPIR -> goToPIR() is GoToNetP -> goToNetP(command.activityParams) + is GoToDuckAI -> goToDuckAI() Reload -> binding.webview.reload() } } @@ -447,6 +453,10 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD globalActivityStarter.start(this, params) } + private fun goToDuckAI() { + duckChat.openDuckChat() + } + private fun renderPurchaseState(purchaseState: PurchaseStateView) { when (purchaseState) { is PurchaseStateView.InProgress, PurchaseStateView.Inactive -> { 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 f4601997d4d0..04ef99bc3be6 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 @@ -790,13 +790,14 @@ class SubscriptionMessagingInterfaceTest { givenInterfaceIsRegistered() givenSubscriptionMessaging(enabled = true) givenAuthV2(enabled = true) + givenDuckAiPlus(enabled = true) val expected = JsRequestResponse.Success( context = "subscriptionPages", featureName = "useSubscription", method = "getFeatureConfig", id = "myId", - result = JSONObject("""{"useSubscriptionsAuthV2":true}"""), + result = JSONObject("""{"useSubscriptionsAuthV2":true,"usePaidDuckAi":true}"""), ) val message = """ @@ -818,13 +819,43 @@ class SubscriptionMessagingInterfaceTest { givenInterfaceIsRegistered() givenSubscriptionMessaging(enabled = true) givenAuthV2(enabled = false) + givenDuckAiPlus(enabled = true) val expected = JsRequestResponse.Success( context = "subscriptionPages", featureName = "useSubscription", method = "getFeatureConfig", id = "myId", - result = JSONObject("""{"useSubscriptionsAuthV2":false}"""), + result = JSONObject("""{"useSubscriptionsAuthV2":false,"usePaidDuckAi":true}"""), + ) + + val message = """ + {"context":"subscriptionPages","featureName":"useSubscription","method":"getFeatureConfig","id":"myId","params":{}} + """.trimIndent() + + messagingInterface.process(message, "duckduckgo-android-messaging-secret") + + val captor = argumentCaptor() + verify(jsMessageHelper).sendJsResponse(captor.capture(), eq(CALLBACK_NAME), eq(SECRET), eq(webView)) + val jsMessage = captor.firstValue + + assertTrue(jsMessage is JsRequestResponse.Success) + checkEquals(expected, jsMessage) + } + + @Test + fun `when process and get feature config and messaging enabled but duck ai plus disabled then return response with duck ai false`() = runTest { + givenInterfaceIsRegistered() + givenSubscriptionMessaging(enabled = true) + givenAuthV2(enabled = true) + givenDuckAiPlus(enabled = false) + + val expected = JsRequestResponse.Success( + context = "subscriptionPages", + featureName = "useSubscription", + method = "getFeatureConfig", + id = "myId", + result = JSONObject("""{"useSubscriptionsAuthV2":true,"usePaidDuckAi":false}"""), ) val message = """ @@ -920,6 +951,12 @@ class SubscriptionMessagingInterfaceTest { whenever(privacyProFeature.enableSubscriptionFlowsV2()).thenReturn(v2SubscriptionFlow) } + private fun givenDuckAiPlus(enabled: Boolean) { + val duckAiPlusToggle = mock() + whenever(duckAiPlusToggle.isEnabled()).thenReturn(enabled) + whenever(privacyProFeature.duckAiPlus()).thenReturn(duckAiPlusToggle) + } + private fun checkEquals(expected: JsRequestResponse, actual: JsRequestResponse) { if (expected is JsRequestResponse.Success && actual is JsRequestResponse.Success) { assertEquals(expected.id, actual.id) 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 76364f37c8e8..31d78665222a 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 @@ -487,6 +487,20 @@ class SubscriptionWebViewViewModelTest { } } + @Test + fun whenFeatureSelectedAndFeatureIsDuckAiThenCommandSent() = runTest { + givenSubscriptionStatus(EXPIRED) + viewModel.commands().test { + viewModel.processJsCallbackMessage( + "test", + "featureSelected", + null, + JSONObject("""{"feature":"${SubscriptionsConstants.DUCK_AI}"}"""), + ) + assertTrue(awaitItem() is Command.GoToDuckAI) + } + } + @Test fun whenSubscriptionSelectedThenPixelIsSent() = runTest { viewModel.processJsCallbackMessage( @@ -622,6 +636,34 @@ class SubscriptionWebViewViewModelTest { verifyNoInteractions(pixelSender) } + @Test + fun whenFeatureSelectedAndFeatureIsDuckAiAndInPurchaseFlowThenPixelIsSent() = runTest { + givenSubscriptionStatus(AUTO_RENEWABLE) + whenever(subscriptionsManager.currentPurchaseState).thenReturn(flowOf(CurrentPurchase.Success)) + viewModel.start() + + viewModel.processJsCallbackMessage( + featureName = "test", + method = "featureSelected", + id = null, + data = JSONObject("""{"feature":"${SubscriptionsConstants.DUCK_AI}"}"""), + ) + verify(pixelSender).reportOnboardingDuckAiClick() + } + + @Test + fun whenFeatureSelectedAndFeatureIsDuckAiAndNotInPurchaseFlowThenPixelIsNotSent() = runTest { + givenSubscriptionStatus(AUTO_RENEWABLE) + + viewModel.processJsCallbackMessage( + featureName = "test", + method = "featureSelected", + id = null, + data = JSONObject("""{"feature":"${SubscriptionsConstants.DUCK_AI}"}"""), + ) + verifyNoInteractions(pixelSender) + } + @Test fun whenSubscriptionsWelcomeFaqClickedAndInPurchaseFlowThenPixelIsSent() = runTest { givenSubscriptionStatus(AUTO_RENEWABLE)