diff --git a/common/common-ui/src/main/res/drawable/ic_ai_chat_grayscale_color_24.xml b/common/common-ui/src/main/res/drawable/ic_ai_chat_grayscale_color_24.xml
new file mode 100644
index 000000000000..9e57a3f67c2f
--- /dev/null
+++ b/common/common-ui/src/main/res/drawable/ic_ai_chat_grayscale_color_24.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/common/common-ui/src/main/res/drawable/ic_new_pill.xml b/common/common-ui/src/main/res/drawable/ic_new_pill.xml
index ed1ce969f86b..c0a9bdae655b 100644
--- a/common/common-ui/src/main/res/drawable/ic_new_pill.xml
+++ b/common/common-ui/src/main/res/drawable/ic_new_pill.xml
@@ -1,3 +1,19 @@
+
+
+
\ No newline at end of file
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt
index 6d7f805dc5b0..d0c4d2259adf 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt
@@ -36,6 +36,9 @@ interface DuckChatFeature {
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun self(): Toggle
+ @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
+ fun duckAiPlus(): Toggle
+
/**
* @return `true` when the remote config has the "duckAiButtonInBrowser" Duck.ai button in browser
* sub-feature flag enabled
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt
index 9f8896e51dd8..9ddcee23e242 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt
@@ -40,6 +40,8 @@ import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_OPEN_HISTO
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_OPEN_MOST_RECENT_HISTORY_CHAT
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_OPEN_NEW_TAB_MENU
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_OPEN_TAB_SWITCHER_FAB
+import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_PAID_OPEN_DUCK_AI_CLICKED
+import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_PAID_SETTINGS_OPENED
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_SEARCHBAR_BUTTON_OPEN
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_SEARCHBAR_SETTING_OFF
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_SEARCHBAR_SETTING_ON
@@ -114,6 +116,8 @@ enum class DuckChatPixelName(override val pixelName: String) : Pixel.PixelName {
DUCK_CHAT_OPEN_HISTORY("aichat_open_history"),
DUCK_CHAT_OPEN_MOST_RECENT_HISTORY_CHAT("aichat_open_most_recent_history_chat"),
DUCK_CHAT_SEND_PROMPT_ONGOING_CHAT("aichat_sent_prompt_ongoing_chat"),
+ DUCK_CHAT_PAID_OPEN_DUCK_AI_CLICKED("m_privacy-pro_settings_paid-ai-chat_click"),
+ DUCK_CHAT_PAID_SETTINGS_OPENED("m_privacy-pro_settings_paid-ai-chat_impression"),
}
object DuckChatPixelParameters {
@@ -148,6 +152,8 @@ class DuckChatParamRemovalPlugin @Inject constructor() : PixelParamRemovalPlugin
DUCK_CHAT_OPEN_HISTORY.pixelName to PixelParameter.removeAtb(),
DUCK_CHAT_OPEN_MOST_RECENT_HISTORY_CHAT.pixelName to PixelParameter.removeAtb(),
DUCK_CHAT_SEND_PROMPT_ONGOING_CHAT.pixelName to PixelParameter.removeAtb(),
+ DUCK_CHAT_PAID_OPEN_DUCK_AI_CLICKED.pixelName to PixelParameter.removeAtb(),
+ DUCK_CHAT_PAID_SETTINGS_OPENED.pixelName to PixelParameter.removeAtb(),
)
}
}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsActivity.kt
new file mode 100644
index 000000000000..0c2372d6b199
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsActivity.kt
@@ -0,0 +1,139 @@
+/*
+ * 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.duckchat.impl.subscription
+
+import android.app.ActivityOptions
+import android.os.Bundle
+import android.text.SpannableStringBuilder
+import android.text.TextPaint
+import android.text.method.LinkMovementMethod
+import android.text.style.ClickableSpan
+import android.text.style.URLSpan
+import android.view.View
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.duckduckgo.anvil.annotations.ContributeToActivityStarter
+import com.duckduckgo.anvil.annotations.InjectWith
+import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams
+import com.duckduckgo.common.ui.DuckDuckGoActivity
+import com.duckduckgo.common.ui.view.getColorFromAttr
+import com.duckduckgo.common.ui.viewbinding.viewBinding
+import com.duckduckgo.common.utils.extensions.html
+import com.duckduckgo.di.scopes.ActivityScope
+import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.impl.R.string
+import com.duckduckgo.duckchat.impl.databinding.ActivityDuckAiPaidSettingsBinding
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPaidSettingsViewModel.Command
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPaidSettingsViewModel.Command.LaunchLearnMoreWebPage
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPaidSettingsViewModel.Command.OpenDuckAi
+import com.duckduckgo.mobile.android.R
+import com.duckduckgo.navigation.api.GlobalActivityStarter
+import javax.inject.Inject
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+object DuckAiPaidSettingsNoParams : GlobalActivityStarter.ActivityParams
+
+@InjectWith(ActivityScope::class)
+@ContributeToActivityStarter(DuckAiPaidSettingsNoParams::class)
+class DuckAiPaidSettingsActivity : DuckDuckGoActivity() {
+
+ @Inject lateinit var globalActivityStarter: GlobalActivityStarter
+
+ @Inject lateinit var duckChat: DuckChat
+
+ private val viewModel: DuckAiPaidSettingsViewModel by bindViewModel()
+ private val binding: ActivityDuckAiPaidSettingsBinding by viewBinding()
+
+ private val toolbar
+ get() = binding.includeToolbar.toolbar
+
+ private val clickableSpan = object : ClickableSpan() {
+ override fun onClick(widget: View) {
+ viewModel.onLearnMoreSelected()
+ }
+
+ override fun updateDrawState(ds: TextPaint) {
+ super.updateDrawState(ds)
+ ds.color = getColorFromAttr(R.attr.daxColorAccentBlue)
+ ds.isUnderlineText = false
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(binding.root)
+ setupToolbar(toolbar)
+
+ configureUiEventHandlers()
+ configureClickableLink()
+ observeViewModel()
+ }
+
+ private fun configureUiEventHandlers() {
+ binding.duckAiPaidSettingsOpenDuckAi.setOnClickListener {
+ viewModel.onOpenDuckAiSelected()
+ }
+ }
+
+ private fun observeViewModel() {
+ viewModel.commands
+ .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
+ .onEach { processCommand(it) }
+ .launchIn(lifecycleScope)
+ }
+
+ private fun processCommand(command: Command) {
+ when (command) {
+ is LaunchLearnMoreWebPage -> {
+ val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle()
+ globalActivityStarter.start(this, WebViewActivityWithParams(command.url, getString(command.titleId)), options)
+ }
+
+ OpenDuckAi -> {
+ duckChat.openDuckChat()
+ }
+ }
+ }
+
+ private fun configureClickableLink() {
+ val htmlText = getString(
+ string.duck_ai_paid_settings_page_description,
+ ).html(this)
+ val spannableString = SpannableStringBuilder(htmlText)
+ val urlSpans = htmlText.getSpans(0, htmlText.length, URLSpan::class.java)
+ urlSpans?.forEach {
+ spannableString.apply {
+ insert(spannableString.getSpanStart(it), "\n")
+ setSpan(
+ clickableSpan,
+ spannableString.getSpanStart(it),
+ spannableString.getSpanEnd(it),
+ spannableString.getSpanFlags(it),
+ )
+ removeSpan(it)
+ trim()
+ }
+ }
+ binding.duckAiPaidSettingsDescription.apply {
+ text = spannableString
+ movementMethod = LinkMovementMethod.getInstance()
+ }
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsViewModel.kt
new file mode 100644
index 000000000000..63d79fae2726
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsViewModel.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.duckchat.impl.subscription
+
+import androidx.annotation.StringRes
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.duckduckgo.anvil.annotations.ContributesViewModel
+import com.duckduckgo.app.statistics.pixels.Pixel
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.di.scopes.ActivityScope
+import com.duckduckgo.duckchat.impl.R
+import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_PAID_OPEN_DUCK_AI_CLICKED
+import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_PAID_SETTINGS_OPENED
+import javax.inject.Inject
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+
+@ContributesViewModel(ActivityScope::class)
+class DuckAiPaidSettingsViewModel @Inject constructor(
+ private val pixel: Pixel,
+ private val dispatchers: DispatcherProvider,
+) : ViewModel() {
+
+ sealed class Command {
+ data object OpenDuckAi : Command()
+ data class LaunchLearnMoreWebPage(
+ val url: String = "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/",
+ @StringRes val titleId: Int = R.string.duck_ai_paid_settings_learn_more_title,
+ ) : Command()
+ }
+
+ private val _commands = Channel(1, BufferOverflow.DROP_OLDEST)
+ val commands = _commands.receiveAsFlow()
+
+ init {
+ pixel.fire(DUCK_CHAT_PAID_SETTINGS_OPENED)
+ }
+
+ fun onLearnMoreSelected() {
+ viewModelScope.launch {
+ _commands.send(Command.LaunchLearnMoreWebPage())
+ }
+ }
+
+ fun onOpenDuckAiSelected() {
+ viewModelScope.launch {
+ _commands.send(Command.OpenDuckAi)
+ pixel.fire(DUCK_CHAT_PAID_OPEN_DUCK_AI_CLICKED)
+ }
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettings.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettings.kt
new file mode 100644
index 000000000000..2bd327cd7116
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettings.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.duckchat.impl.subscription
+
+import android.content.Context
+import android.view.View
+import com.duckduckgo.anvil.annotations.PriorityKey
+import com.duckduckgo.di.scopes.ActivityScope
+import com.duckduckgo.settings.api.ProSettingsPlugin
+import com.squareup.anvil.annotations.ContributesMultibinding
+import javax.inject.Inject
+
+@ContributesMultibinding(scope = ActivityScope::class)
+@PriorityKey(350)
+class DuckAiPlusSettings @Inject constructor() : ProSettingsPlugin {
+ override fun getView(context: Context): View {
+ return DuckAiPlusSettingsView(context)
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettingsView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettingsView.kt
new file mode 100644
index 000000000000..b7e044a351a6
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettingsView.kt
@@ -0,0 +1,122 @@
+/*
+ * 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.duckchat.impl.subscription
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.FrameLayout
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.findViewTreeLifecycleOwner
+import androidx.lifecycle.findViewTreeViewModelStoreOwner
+import androidx.lifecycle.lifecycleScope
+import com.duckduckgo.anvil.annotations.InjectWith
+import com.duckduckgo.common.ui.viewbinding.viewBinding
+import com.duckduckgo.common.utils.ConflatedJob
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.common.utils.ViewViewModelFactory
+import com.duckduckgo.di.scopes.ViewScope
+import com.duckduckgo.duckchat.impl.databinding.ViewDuckAiSettingsBinding
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.Command
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.Command.OpenDuckAiPlusSettings
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState.SettingState
+import com.duckduckgo.navigation.api.GlobalActivityStarter
+import dagger.android.support.AndroidSupportInjection
+import javax.inject.Inject
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+@InjectWith(ViewScope::class)
+class DuckAiPlusSettingsView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyle: Int = 0,
+) : FrameLayout(context, attrs, defStyle) {
+
+ @Inject
+ lateinit var viewModelFactory: ViewViewModelFactory
+
+ @Inject
+ lateinit var globalActivityStarter: GlobalActivityStarter
+
+ @Inject
+ lateinit var dispatchers: DispatcherProvider
+
+ private val binding: ViewDuckAiSettingsBinding by viewBinding()
+
+ private val viewModel: DuckAiPlusSettingsViewModel by lazy {
+ ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[DuckAiPlusSettingsViewModel::class.java]
+ }
+
+ private var job: ConflatedJob = ConflatedJob()
+ private var conflatedStateJob: ConflatedJob = ConflatedJob()
+
+ override fun onAttachedToWindow() {
+ AndroidSupportInjection.inject(this)
+ super.onAttachedToWindow()
+
+ findViewTreeLifecycleOwner()?.lifecycle?.addObserver(viewModel)
+ val coroutineScope = findViewTreeLifecycleOwner()?.lifecycleScope
+
+ job += viewModel.commands()
+ .onEach { processCommands(it) }
+ .launchIn(coroutineScope!!)
+
+ conflatedStateJob += viewModel.viewState
+ .onEach { renderView(it) }
+ .launchIn(coroutineScope!!)
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ findViewTreeLifecycleOwner()?.lifecycle?.removeObserver(viewModel)
+ job.cancel()
+ conflatedStateJob.cancel()
+ }
+
+ private fun renderView(viewState: ViewState) {
+ with(binding.duckAiSettings) {
+ when (viewState.settingState) {
+ is SettingState.Enabled -> {
+ isVisible = true
+ setStatus(isOn = true)
+ setLeadingIconResource(com.duckduckgo.mobile.android.R.drawable.ic_ai_chat_color_24)
+ isClickable = true
+ setClickListener { viewModel.onDuckAiClicked() }
+ }
+ SettingState.Disabled -> {
+ isVisible = true
+ isClickable = false
+ setStatus(isOn = false)
+ setClickListener(null)
+ setLeadingIconResource(com.duckduckgo.mobile.android.R.drawable.ic_ai_chat_grayscale_color_24)
+ }
+ SettingState.Hidden -> isGone = true
+ }
+ }
+ }
+
+ private fun processCommands(command: Command) {
+ when (command) {
+ is OpenDuckAiPlusSettings -> {
+ globalActivityStarter.start(context, DuckAiPaidSettingsNoParams)
+ }
+ }
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettingsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettingsViewModel.kt
new file mode 100644
index 000000000000..e7c97ed58c8b
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettingsViewModel.kt
@@ -0,0 +1,141 @@
+/*
+ * 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.duckchat.impl.subscription
+
+import android.annotation.SuppressLint
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.duckduckgo.anvil.annotations.ContributesViewModel
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.di.scopes.ViewScope
+import com.duckduckgo.duckchat.impl.feature.DuckChatFeature
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState.SettingState
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState.SettingState.Disabled
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState.SettingState.Hidden
+import com.duckduckgo.subscriptions.api.Product.DuckAiPlus
+import com.duckduckgo.subscriptions.api.SubscriptionStatus
+import com.duckduckgo.subscriptions.api.Subscriptions
+import javax.inject.Inject
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+@SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle
+@ContributesViewModel(ViewScope::class)
+class DuckAiPlusSettingsViewModel @Inject constructor(
+ private val subscriptions: Subscriptions,
+ private val duckChatFeature: DuckChatFeature,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel(), DefaultLifecycleObserver {
+
+ sealed class Command {
+ data object OpenDuckAiPlusSettings : Command()
+ }
+
+ private val command = Channel(1, BufferOverflow.DROP_OLDEST)
+ internal fun commands(): Flow = command.receiveAsFlow()
+ data class ViewState(val settingState: SettingState = Hidden) {
+
+ sealed class SettingState {
+
+ data object Hidden : SettingState()
+ data object Enabled : SettingState()
+ data object Disabled : SettingState()
+ }
+ }
+
+ private val _viewState = MutableStateFlow(ViewState())
+ val viewState = _viewState.asStateFlow()
+
+ fun onDuckAiClicked() {
+ sendCommand(Command.OpenDuckAiPlusSettings)
+ }
+
+ override fun onCreate(owner: LifecycleOwner) {
+ super.onCreate(owner)
+
+ viewModelScope.launch(dispatcherProvider.io()) {
+ if (duckChatFeature.duckAiPlus().isEnabled().not()) {
+ _viewState.update { it.copy(settingState = Hidden) }
+ return@launch
+ }
+
+ subscriptions.getEntitlementStatus().map { entitlements ->
+ entitlements.any { product ->
+ product == DuckAiPlus
+ }
+ }.onEach { hasValidEntitlement ->
+ val subscriptionStatus = subscriptions.getSubscriptionStatus()
+ val state = getDuckAiProState(hasValidEntitlement, subscriptionStatus)
+ _viewState.update { it.copy(settingState = state) }
+ }.launchIn(viewModelScope)
+ }
+ }
+
+ private suspend fun getDuckAiProState(
+ hasValidEntitlement: Boolean,
+ subscriptionStatus: SubscriptionStatus,
+ ): SettingState {
+ return when (subscriptionStatus) {
+ SubscriptionStatus.UNKNOWN -> Hidden
+ SubscriptionStatus.INACTIVE,
+ SubscriptionStatus.EXPIRED,
+ SubscriptionStatus.WAITING,
+ -> {
+ if (isDuckAiProAvailable()) {
+ Disabled
+ } else {
+ Hidden
+ }
+ }
+
+ SubscriptionStatus.AUTO_RENEWABLE,
+ SubscriptionStatus.NOT_AUTO_RENEWABLE,
+ SubscriptionStatus.GRACE_PERIOD,
+ -> {
+ if (hasValidEntitlement) {
+ SettingState.Enabled
+ } else {
+ Hidden
+ }
+ }
+ }
+ }
+
+ private suspend fun isDuckAiProAvailable(): Boolean {
+ return subscriptions.getAvailableProducts()
+ .any { availableProduct ->
+ availableProduct == DuckAiPlus
+ }
+ }
+
+ private fun sendCommand(newCommand: Command) {
+ viewModelScope.launch {
+ command.send(newCommand)
+ }
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt
index d8c51fc82b0b..076b0cd1c35f 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt
@@ -70,8 +70,10 @@ import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromEx
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.NoMediaCaptured
import com.duckduckgo.js.messaging.api.JsMessageCallback
import com.duckduckgo.js.messaging.api.JsMessaging
+import com.duckduckgo.js.messaging.api.SubscriptionEventData
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.navigation.api.getActivityParams
+import com.duckduckgo.subscriptions.api.SUBSCRIPTIONS_FEATURE_NAME
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import java.io.File
@@ -79,6 +81,8 @@ import javax.inject.Inject
import javax.inject.Named
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.cancellable
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
@@ -91,6 +95,8 @@ internal data class DuckChatWebViewActivityWithParams(
@ContributeToActivityStarter(DuckChatWebViewActivityWithParams::class)
open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationDialogListener {
+ private val viewModel: DuckChatWebViewActivityViewModel by bindViewModel()
+
@Inject
lateinit var webViewClient: DuckChatWebViewClient
@@ -101,6 +107,9 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
@Inject
lateinit var duckChatJSHelper: DuckChatJSHelper
+ @Inject
+ lateinit var subscriptionsHandler: SubscriptionsHandler
+
@Inject
@AppCoroutineScope
lateinit var appCoroutineScope: CoroutineScope
@@ -240,6 +249,19 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
}
}
}
+
+ SUBSCRIPTIONS_FEATURE_NAME -> {
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ this@DuckChatWebViewActivity,
+ appCoroutineScope,
+ contentScopeScripts,
+ )
+ }
+
else -> {}
}
}
@@ -267,6 +289,21 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
}
pendingUploadTask = null
}
+
+ // Observe ViewModel commands
+ viewModel.commands
+ .onEach { command ->
+ when (command) {
+ is DuckChatWebViewActivityViewModel.Command.SendSubscriptionAuthUpdateEvent -> {
+ val authUpdateEvent = SubscriptionEventData(
+ featureName = SUBSCRIPTIONS_FEATURE_NAME,
+ subscriptionName = "authUpdate",
+ params = org.json.JSONObject(),
+ )
+ contentScopeScripts.sendSubscriptionEvent(authUpdateEvent)
+ }
+ }
+ }.launchIn(lifecycleScope)
}
data class FileChooserRequestedParams(
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivityViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivityViewModel.kt
new file mode 100644
index 000000000000..1bc3277e7c2a
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivityViewModel.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.duckchat.impl.ui
+
+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.Subscriptions
+import javax.inject.Inject
+import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+
+@ContributesViewModel(ActivityScope::class)
+class DuckChatWebViewActivityViewModel @Inject constructor(
+ private val subscriptions: Subscriptions,
+) : ViewModel() {
+
+ private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST)
+ val commands = commandChannel.receiveAsFlow()
+
+ sealed class Command {
+ data object SendSubscriptionAuthUpdateEvent : Command()
+ }
+
+ init {
+ observeSubscriptionChanges()
+ }
+
+ private fun observeSubscriptionChanges() {
+ subscriptions.getSubscriptionStatusFlow()
+ .distinctUntilChanged()
+ .onEach { _ ->
+ commandChannel.trySend(Command.SendSubscriptionAuthUpdateEvent)
+ }.launchIn(viewModelScope)
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt
index b95666fe4b08..99c619d69d14 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt
@@ -37,6 +37,7 @@ import android.webkit.WebView
import androidx.annotation.AnyThread
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
+import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.app.di.AppCoroutineScope
@@ -48,6 +49,7 @@ import com.duckduckgo.common.ui.view.makeSnackbarWithNoBottomInset
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.common.utils.FragmentViewModelFactory
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.downloads.api.DOWNLOAD_SNACKBAR_DELAY
import com.duckduckgo.downloads.api.DOWNLOAD_SNACKBAR_LENGTH
@@ -72,6 +74,8 @@ import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromEx
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.NoMediaCaptured
import com.duckduckgo.js.messaging.api.JsMessageCallback
import com.duckduckgo.js.messaging.api.JsMessaging
+import com.duckduckgo.js.messaging.api.SubscriptionEventData
+import com.duckduckgo.subscriptions.api.SUBSCRIPTIONS_FEATURE_NAME
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import java.io.File
@@ -79,6 +83,8 @@ import javax.inject.Inject
import javax.inject.Named
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.cancellable
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
@@ -86,6 +92,13 @@ import org.json.JSONObject
@InjectWith(FragmentScope::class)
open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_chat_webview), DownloadConfirmationDialogListener {
+ @Inject
+ lateinit var viewModelFactory: FragmentViewModelFactory
+
+ private val viewModel: DuckChatWebViewViewModel by lazy {
+ ViewModelProvider(this, viewModelFactory)[DuckChatWebViewViewModel::class.java]
+ }
+
@Inject
lateinit var webViewClient: DuckChatWebViewClient
@@ -96,6 +109,9 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c
@Inject
lateinit var duckChatJSHelper: DuckChatJSHelper
+ @Inject
+ lateinit var subscriptionsHandler: SubscriptionsHandler
+
@Inject
@AppCoroutineScope
lateinit var appCoroutineScope: CoroutineScope
@@ -236,6 +252,18 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c
}
}
+ SUBSCRIPTIONS_FEATURE_NAME -> {
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ requireActivity(),
+ appCoroutineScope,
+ contentScopeScripts,
+ )
+ }
+
else -> {}
}
}
@@ -263,6 +291,21 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c
}
pendingUploadTask = null
}
+
+ // Observe ViewModel commands
+ viewModel.commands
+ .onEach { command ->
+ when (command) {
+ is DuckChatWebViewViewModel.Command.SendSubscriptionAuthUpdateEvent -> {
+ val authUpdateEvent = SubscriptionEventData(
+ featureName = SUBSCRIPTIONS_FEATURE_NAME,
+ subscriptionName = "authUpdate",
+ params = JSONObject(),
+ )
+ contentScopeScripts.sendSubscriptionEvent(authUpdateEvent)
+ }
+ }
+ }.launchIn(lifecycleScope)
}
data class FileChooserRequestedParams(
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt
new file mode 100644
index 000000000000..6fb3206b8504
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.duckchat.impl.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.duckduckgo.anvil.annotations.ContributesViewModel
+import com.duckduckgo.di.scopes.FragmentScope
+import com.duckduckgo.subscriptions.api.Subscriptions
+import javax.inject.Inject
+import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+
+@ContributesViewModel(FragmentScope::class)
+class DuckChatWebViewViewModel @Inject constructor(
+ private val subscriptions: Subscriptions,
+) : ViewModel() {
+
+ private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST)
+ val commands = commandChannel.receiveAsFlow()
+
+ sealed class Command {
+ data object SendSubscriptionAuthUpdateEvent : Command()
+ }
+
+ init {
+ observeSubscriptionChanges()
+ }
+
+ private fun observeSubscriptionChanges() {
+ subscriptions.getSubscriptionStatusFlow()
+ .distinctUntilChanged()
+ .onEach { _ ->
+ commandChannel.trySend(Command.SendSubscriptionAuthUpdateEvent)
+ }.launchIn(viewModelScope)
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/SubscriptionsHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/SubscriptionsHandler.kt
new file mode 100644
index 000000000000..eceebe39ff7a
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/SubscriptionsHandler.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.duckchat.impl.ui
+
+import android.content.Context
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.js.messaging.api.JsMessaging
+import com.duckduckgo.navigation.api.GlobalActivityStarter
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.RestoreSubscriptionScreenWithParams
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionScreenNoParams
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionsSettingsScreenWithEmptyParams
+import com.duckduckgo.subscriptions.api.SubscriptionsJSHelper
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+
+class SubscriptionsHandler @Inject constructor(
+ private val subscriptionsJSHelper: SubscriptionsJSHelper,
+ private val globalActivityStarter: GlobalActivityStarter,
+ private val dispatcherProvider: DispatcherProvider,
+) {
+
+ fun handleSubscriptionsFeature(
+ featureName: String,
+ method: String,
+ id: String?,
+ data: JSONObject?,
+ context: Context,
+ appCoroutineScope: CoroutineScope,
+ contentScopeScripts: JsMessaging,
+ ) {
+ appCoroutineScope.launch(dispatcherProvider.io()) {
+ val response = subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data)
+ withContext(dispatcherProvider.main()) {
+ response?.let {
+ contentScopeScripts.onResponse(response)
+ }
+ }
+
+ when (method) {
+ METHOD_BACK_TO_SETTINGS -> {
+ withContext(dispatcherProvider.main()) {
+ globalActivityStarter.start(context, SubscriptionsSettingsScreenWithEmptyParams)
+ }
+ }
+
+ METHOD_OPEN_SUBSCRIPTION_ACTIVATION -> {
+ withContext(dispatcherProvider.main()) {
+ globalActivityStarter.start(context, RestoreSubscriptionScreenWithParams(isOriginWeb = true))
+ }
+ }
+
+ METHOD_OPEN_SUBSCRIPTION_PURCHASE -> {
+ withContext(dispatcherProvider.main()) {
+ globalActivityStarter.start(context, SubscriptionScreenNoParams)
+ }
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ companion object {
+ private const val METHOD_BACK_TO_SETTINGS = "backToSettings"
+ private const val METHOD_OPEN_SUBSCRIPTION_ACTIVATION = "openSubscriptionActivation"
+ private const val METHOD_OPEN_SUBSCRIPTION_PURCHASE = "openSubscriptionPurchase"
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/res/drawable/duckai_ddg_128.xml b/duckchat/duckchat-impl/src/main/res/drawable/duckai_ddg_128.xml
new file mode 100644
index 000000000000..bdf6c31713b6
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/res/drawable/duckai_ddg_128.xml
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/duckchat/duckchat-impl/src/main/res/layout/activity_duck_ai_paid_settings.xml b/duckchat/duckchat-impl/src/main/res/layout/activity_duck_ai_paid_settings.xml
new file mode 100644
index 000000000000..896a06ba380c
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/res/layout/activity_duck_ai_paid_settings.xml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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
new file mode 100644
index 000000000000..84f30ae551e4
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/res/layout/view_duck_ai_settings.xml
@@ -0,0 +1,25 @@
+
+
+
\ No newline at end of file
diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
index 3eb33fc4daef..50347dfe2617 100644
--- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
+++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
@@ -38,4 +38,11 @@
Autocomplete Suggestions now include your recently visited sites. Turn off in Settings, or clear anytime with the 🔥 Fire Button.
Same privacy.\nBetter search suggestions!
Open Settings
+
+
+ Duck.ai
+ Duck.ai
+ Learn More]]>
+ Open Duck.ai
+ Learn more
\ No newline at end of file
diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivityViewModelTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivityViewModelTest.kt
new file mode 100644
index 000000000000..1e727b5164d7
--- /dev/null
+++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivityViewModelTest.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.duckchat.impl.ui
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import app.cash.turbine.test
+import com.duckduckgo.common.test.CoroutineTestRule
+import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewActivityViewModel.Command
+import com.duckduckgo.subscriptions.api.SubscriptionStatus
+import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE
+import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED
+import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE
+import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
+import com.duckduckgo.subscriptions.api.Subscriptions
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@RunWith(AndroidJUnit4::class)
+class DuckChatWebViewActivityViewModelTest {
+
+ @get:Rule
+ val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
+
+ private val subscriptions: Subscriptions = mock()
+ private val subscriptionStatusFlow = MutableSharedFlow()
+
+ private lateinit var viewModel: DuckChatWebViewActivityViewModel
+
+ @Before
+ fun setup() {
+ whenever(subscriptions.getSubscriptionStatusFlow()).thenReturn(subscriptionStatusFlow)
+ viewModel = DuckChatWebViewActivityViewModel(subscriptions)
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesToActiveThenSendSubscriptionAuthUpdateEventCommand() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesToInactiveThenSendSubscriptionAuthUpdateEventCommand() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(INACTIVE)
+
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesToExpiredThenSendSubscriptionAuthUpdateEventCommand() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(EXPIRED)
+
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesToUnknownThenSendSubscriptionAuthUpdateEventCommand() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(UNKNOWN)
+
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesTwiceToSameValueThenOnlyOneCommandSent() = runTest {
+ viewModel.commands.test {
+ // Emit the same status twice
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+
+ // Should only receive one command due to distinctUntilChanged
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesTwiceToDifferentValuesThenTwoCommandsSent() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+ subscriptionStatusFlow.emit(EXPIRED)
+
+ val firstCommand = awaitItem()
+ assertTrue(firstCommand is Command.SendSubscriptionAuthUpdateEvent)
+
+ val secondCommand = awaitItem()
+ assertTrue(secondCommand is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenMultipleSubscriptionStatusChangesOccurThenCorrespondingCommandsSent() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(UNKNOWN)
+ subscriptionStatusFlow.emit(INACTIVE)
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+ subscriptionStatusFlow.emit(EXPIRED)
+
+ repeat(4) {
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+ }
+}
diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModelTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModelTest.kt
new file mode 100644
index 000000000000..6e2c33e42ef9
--- /dev/null
+++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModelTest.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.duckchat.impl.ui
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import app.cash.turbine.test
+import com.duckduckgo.common.test.CoroutineTestRule
+import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewViewModel.Command
+import com.duckduckgo.subscriptions.api.SubscriptionStatus
+import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE
+import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED
+import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE
+import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
+import com.duckduckgo.subscriptions.api.Subscriptions
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@RunWith(AndroidJUnit4::class)
+class DuckChatWebViewViewModelTest {
+
+ @get:Rule
+ val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
+
+ private val subscriptions: Subscriptions = mock()
+ private val subscriptionStatusFlow = MutableSharedFlow()
+
+ private lateinit var viewModel: DuckChatWebViewViewModel
+
+ @Before
+ fun setup() {
+ whenever(subscriptions.getSubscriptionStatusFlow()).thenReturn(subscriptionStatusFlow)
+ viewModel = DuckChatWebViewViewModel(subscriptions)
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesToActiveThenSendSubscriptionAuthUpdateEventCommand() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesToInactiveThenSendSubscriptionAuthUpdateEventCommand() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(INACTIVE)
+
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesToExpiredThenSendSubscriptionAuthUpdateEventCommand() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(EXPIRED)
+
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesToUnknownThenSendSubscriptionAuthUpdateEventCommand() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(UNKNOWN)
+
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesTwiceToSameValueThenOnlyOneCommandSent() = runTest {
+ viewModel.commands.test {
+ // Emit the same status twice
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+
+ // Should only receive one command due to distinctUntilChanged
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun whenSubscriptionStatusChangesTwiceToDifferentValuesThenTwoCommandsSent() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+ subscriptionStatusFlow.emit(EXPIRED)
+
+ val firstCommand = awaitItem()
+ assertTrue(firstCommand is Command.SendSubscriptionAuthUpdateEvent)
+
+ val secondCommand = awaitItem()
+ assertTrue(secondCommand is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+
+ @Test
+ fun whenMultipleSubscriptionStatusChangesOccurThenCorrespondingCommandsSent() = runTest {
+ viewModel.commands.test {
+ subscriptionStatusFlow.emit(UNKNOWN)
+ subscriptionStatusFlow.emit(INACTIVE)
+ subscriptionStatusFlow.emit(AUTO_RENEWABLE)
+ subscriptionStatusFlow.emit(EXPIRED)
+
+ repeat(4) {
+ val command = awaitItem()
+ assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
+ }
+ }
+ }
+}
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsViewModelTest.kt
new file mode 100644
index 000000000000..af8e368ae001
--- /dev/null
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsViewModelTest.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.duckchat.impl.subscription
+
+import app.cash.turbine.test
+import com.duckduckgo.app.statistics.pixels.Pixel
+import com.duckduckgo.common.test.CoroutineTestRule
+import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_PAID_OPEN_DUCK_AI_CLICKED
+import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_PAID_SETTINGS_OPENED
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPaidSettingsViewModel.Command
+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
+import org.mockito.kotlin.verify
+
+class DuckAiPaidSettingsViewModelTest {
+
+ @get:Rule
+ val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
+
+ private val mockPixel: Pixel = mock()
+
+ private lateinit var testee: DuckAiPaidSettingsViewModel
+
+ @Before
+ fun setUp() {
+ testee = DuckAiPaidSettingsViewModel(
+ pixel = mockPixel,
+ dispatchers = coroutineTestRule.testDispatcherProvider,
+ )
+ }
+
+ @Test
+ fun `when viewModel is initialized then settings opened pixel is fired`() {
+ verify(mockPixel).fire(DUCK_CHAT_PAID_SETTINGS_OPENED)
+ }
+
+ @Test
+ fun `when onLearnMoreSelected is called then LaunchLearnMoreWebPage command is emitted`() = runTest {
+ testee.commands.test {
+ testee.onLearnMoreSelected()
+ assertEquals(Command.LaunchLearnMoreWebPage(), awaitItem())
+ }
+ }
+
+ @Test
+ fun `when onOpenDuckAiSelected is called then OpenDuckAi command is emitted`() = runTest {
+ testee.commands.test {
+ testee.onOpenDuckAiSelected()
+ assertEquals(Command.OpenDuckAi, awaitItem())
+ }
+ }
+
+ @Test
+ fun `when onOpenDuckAiSelected is called then pixel is fired`() = runTest {
+ testee.onOpenDuckAiSelected()
+ verify(mockPixel).fire(DUCK_CHAT_PAID_OPEN_DUCK_AI_CLICKED)
+ }
+
+ @Test
+ fun `when LaunchLearnMoreWebPage command is created then it has correct default values`() {
+ val command = Command.LaunchLearnMoreWebPage()
+ assertEquals("https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/", command.url)
+ assertEquals(com.duckduckgo.duckchat.impl.R.string.duck_ai_paid_settings_learn_more_title, command.titleId)
+ }
+
+ @Test
+ fun `when LaunchLearnMoreWebPage command is created with custom values then it uses those values`() {
+ val customUrl = "https://example.com/custom"
+ val customTitleId = 123
+ val command = Command.LaunchLearnMoreWebPage(url = customUrl, titleId = customTitleId)
+ assertEquals(customUrl, command.url)
+ assertEquals(customTitleId, command.titleId)
+ }
+}
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettingsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettingsViewModelTest.kt
new file mode 100644
index 000000000000..5a06d31c19c8
--- /dev/null
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/subscription/DuckAiPlusSettingsViewModelTest.kt
@@ -0,0 +1,220 @@
+package com.duckduckgo.duckchat.impl.subscription
+
+import android.annotation.SuppressLint
+import app.cash.turbine.test
+import com.duckduckgo.common.test.CoroutineTestRule
+import com.duckduckgo.duckchat.impl.feature.DuckChatFeature
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.Command.OpenDuckAiPlusSettings
+import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState.SettingState
+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.SubscriptionStatus
+import com.duckduckgo.subscriptions.api.Subscriptions
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@SuppressLint("DenyListedApi")
+class DuckAiPlusSettingsViewModelTest {
+ @get:Rule
+ val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
+
+ private val subscriptions: Subscriptions = mock()
+ private val duckChatFeature = FakeFeatureToggleFactory.create(DuckChatFeature::class.java).also {
+ it.duckAiPlus().setRawStoredState(State(true))
+ }
+
+ private val viewModel: DuckAiPlusSettingsViewModel by lazy {
+ DuckAiPlusSettingsViewModel(
+ subscriptions = subscriptions,
+ duckChatFeature = duckChatFeature,
+ dispatcherProvider = coroutineTestRule.testDispatcherProvider,
+ )
+ }
+
+ @Test
+ fun `when feature flag is disabled then SettingState is Hidden`() = runTest {
+ duckChatFeature.duckAiPlus().setRawStoredState(State(false))
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.DuckAiPlus)))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE)
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Hidden, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when onDuckAiPlusClicked then OpenDuckAiSettings command is sent`() = runTest {
+ viewModel.commands().test {
+ viewModel.onDuckAiClicked()
+ assertEquals(OpenDuckAiPlusSettings, awaitItem())
+ }
+ }
+
+ @Test
+ fun `when subscription state is unknown then SettingState is Hidden`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.UNKNOWN)
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Hidden, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is inactive and no DuckAiPlus product available then SettingState is Hidden`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE)
+ whenever(subscriptions.getAvailableProducts()).thenReturn(emptySet())
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Hidden, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is inactive and DuckAiPlus product available then SettingState is Disabled`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.INACTIVE)
+ whenever(subscriptions.getAvailableProducts()).thenReturn(setOf(Product.DuckAiPlus))
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Disabled, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is expired and no DuckAiPlus product available then SettingState is Hidden`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.EXPIRED)
+ whenever(subscriptions.getAvailableProducts()).thenReturn(emptySet())
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Hidden, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is expired and DuckAiPlus product available then SettingState is Disabled`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.EXPIRED)
+ whenever(subscriptions.getAvailableProducts()).thenReturn(setOf(Product.DuckAiPlus))
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Disabled, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is waiting and no DuckAiPlus product available then SettingState is Hidden`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.WAITING)
+ whenever(subscriptions.getAvailableProducts()).thenReturn(emptySet())
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Hidden, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is waiting and DuckAiPlus product available then SettingState is Disabled`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.WAITING)
+ whenever(subscriptions.getAvailableProducts()).thenReturn(setOf(Product.DuckAiPlus))
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Disabled, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is auto renewable and DuckAiPlus entitled then SettingState is Enabled`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.DuckAiPlus)))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE)
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Enabled, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is auto renewable and not entitled then SettingState is Hidden`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE)
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Hidden, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is not auto renewable and DuckAiPlus entitled then SettingState is Enabled`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.DuckAiPlus)))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.NOT_AUTO_RENEWABLE)
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Enabled, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is not auto renewable and not entitled then SettingState is Hidden`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.NOT_AUTO_RENEWABLE)
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Hidden, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is grace period and DuckAiPlus entitled then SettingState is Enabled`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.DuckAiPlus)))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.GRACE_PERIOD)
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Enabled, expectMostRecentItem().settingState)
+ }
+ }
+
+ @Test
+ fun `when subscription state is grace period and not entitled then SettingState is Hidden`() = runTest {
+ whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(emptyList()))
+ whenever(subscriptions.getSubscriptionStatus()).thenReturn(SubscriptionStatus.GRACE_PERIOD)
+
+ viewModel.onCreate(mock())
+
+ viewModel.viewState.test {
+ assertEquals(SettingState.Hidden, expectMostRecentItem().settingState)
+ }
+ }
+}
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/SubscriptionsHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/SubscriptionsHandlerTest.kt
new file mode 100644
index 000000000000..15cee5569898
--- /dev/null
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/SubscriptionsHandlerTest.kt
@@ -0,0 +1,235 @@
+/*
+ * 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.duckchat.impl.ui
+
+import android.content.Context
+import com.duckduckgo.common.test.CoroutineTestRule
+import com.duckduckgo.js.messaging.api.JsCallbackData
+import com.duckduckgo.js.messaging.api.JsMessaging
+import com.duckduckgo.navigation.api.GlobalActivityStarter
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.RestoreSubscriptionScreenWithParams
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionScreenNoParams
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionsSettingsScreenWithEmptyParams
+import com.duckduckgo.subscriptions.api.SubscriptionsJSHelper
+import kotlinx.coroutines.test.runTest
+import org.json.JSONObject
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.any
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+class SubscriptionsHandlerTest {
+
+ @get:Rule
+ var coroutineRule = CoroutineTestRule()
+
+ private val dispatcherProvider = coroutineRule.testDispatcherProvider
+
+ @Mock
+ private lateinit var subscriptionsJSHelper: SubscriptionsJSHelper
+
+ @Mock
+ private lateinit var globalActivityStarter: GlobalActivityStarter
+
+ @Mock
+ private lateinit var context: Context
+
+ @Mock
+ private lateinit var contentScopeScripts: JsMessaging
+
+ private lateinit var subscriptionsHandler: SubscriptionsHandler
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ subscriptionsHandler = SubscriptionsHandler(
+ subscriptionsJSHelper,
+ globalActivityStarter,
+ dispatcherProvider,
+ )
+ }
+
+ @Test
+ fun `handleSubscriptionsFeature processes js callback and responds when response is not null`() = runTest {
+ val featureName = "subscriptions"
+ val method = "someMethod"
+ val id = "testId"
+ val data = JSONObject()
+ val response = JsCallbackData(JSONObject(), featureName, method, id)
+ whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
+ .thenReturn(response)
+
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ context,
+ coroutineRule.testScope,
+ contentScopeScripts,
+ )
+
+ verify(subscriptionsJSHelper).processJsCallbackMessage(featureName, method, id, data)
+ verify(contentScopeScripts).onResponse(response)
+ }
+
+ @Test
+ fun `handleSubscriptionsFeature processes js callback but does not respond when response is null`() = runTest {
+ val featureName = "subscriptions"
+ val method = "someMethod"
+ val id = "testId"
+ val data = JSONObject()
+ whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
+ .thenReturn(null)
+
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ context,
+ coroutineRule.testScope,
+ contentScopeScripts,
+ )
+
+ verify(subscriptionsJSHelper).processJsCallbackMessage(featureName, method, id, data)
+ verify(contentScopeScripts, never()).onResponse(any())
+ }
+
+ @Test
+ fun `handleSubscriptionsFeature launches settings screen when method is backToSettings`() = runTest {
+ val featureName = "subscriptions"
+ val method = "backToSettings"
+ val id = "testId"
+ val data = JSONObject()
+ val response = JsCallbackData(JSONObject(), featureName, method, id)
+ whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
+ .thenReturn(response)
+
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ context,
+ coroutineRule.testScope,
+ contentScopeScripts,
+ )
+
+ verify(globalActivityStarter).start(context, SubscriptionsSettingsScreenWithEmptyParams)
+ }
+
+ @Test
+ fun `handleSubscriptionsFeature launches subscription activation screen when method is openSubscriptionActivation`() = runTest {
+ val featureName = "subscriptions"
+ val method = "openSubscriptionActivation"
+ val id = "testId"
+ val data = JSONObject()
+ val response = JsCallbackData(JSONObject(), featureName, method, id)
+ whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
+ .thenReturn(response)
+
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ context,
+ coroutineRule.testScope,
+ contentScopeScripts,
+ )
+
+ verify(globalActivityStarter).start(context, RestoreSubscriptionScreenWithParams(isOriginWeb = true))
+ }
+
+ @Test
+ fun `handleSubscriptionsFeature launches subscription purchase screen when method is openSubscriptionPurchase`() = runTest {
+ val featureName = "subscriptions"
+ val method = "openSubscriptionPurchase"
+ val id = "testId"
+ val data = JSONObject()
+ val response = JsCallbackData(JSONObject(), featureName, method, id)
+ whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
+ .thenReturn(response)
+
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ context,
+ coroutineRule.testScope,
+ contentScopeScripts,
+ )
+
+ verify(globalActivityStarter).start(context, SubscriptionScreenNoParams)
+ }
+
+ @Test
+ fun `handleSubscriptionsFeature handles null data parameter`() = runTest {
+ val featureName = "subscriptions"
+ val method = "backToSettings"
+ val id = "testId"
+ val data: JSONObject? = null
+ val response = JsCallbackData(JSONObject(), featureName, method, id)
+ whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
+ .thenReturn(response)
+
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ context,
+ coroutineRule.testScope,
+ contentScopeScripts,
+ )
+
+ verify(subscriptionsJSHelper).processJsCallbackMessage(featureName, method, id, data)
+ verify(globalActivityStarter).start(context, SubscriptionsSettingsScreenWithEmptyParams)
+ }
+
+ @Test
+ fun `handleSubscriptionsFeature handles null id parameter`() = runTest {
+ val featureName = "subscriptions"
+ val method = "openSubscriptionPurchase"
+ val id: String? = null
+ val data = JSONObject()
+ val response = JsCallbackData(JSONObject(), featureName, method, "")
+ whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
+ .thenReturn(response)
+
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ context,
+ coroutineRule.testScope,
+ contentScopeScripts,
+ )
+
+ verify(subscriptionsJSHelper).processJsCallbackMessage(featureName, method, id, data)
+ verify(globalActivityStarter).start(context, SubscriptionScreenNoParams)
+ }
+}
diff --git a/network-protection/network-protection-impl/src/main/res/drawable/ic_vpn_grayscale_color_24.xml b/network-protection/network-protection-impl/src/main/res/drawable/ic_vpn_grayscale_color_24.xml
index 6e3c09250c3e..8d1cdc11b5e9 100644
--- a/network-protection/network-protection-impl/src/main/res/drawable/ic_vpn_grayscale_color_24.xml
+++ b/network-protection/network-protection-impl/src/main/res/drawable/ic_vpn_grayscale_color_24.xml
@@ -1,46 +1,45 @@
-
-
+ android:pathData="M12,2.625A9.375,9.375 0,0 1,21.375 12c0,1.218 0.124,1.958 -0.375,0.565a4.25,4.25 0,0 0,-3.89 -2.814L17,9.75A4.25,4.25 0,0 0,12.75 14v0.865a2.8,2.8 0,0 0,-2 2.68v2.91c0.015,1.097 -0.538,0.787 -1.372,0.545A9.375,9.375 0,0 1,12 2.625">
+
+
+
+
+
+
+
-
+ android:pathData="M12,2c5.523,0 10,4.477 10,10q0,0.406 -0.032,0.805c-0.094,1.18 -0.856,1.278 -1.02,0.105 -0.059,-0.421 -0.136,-0.81 -0.283,-1.061l0.08,-0.039a8.75,8.75 0,0 0,-5.889 -8.08c0.302,0.412 0.572,0.878 0.804,1.376 0.595,1.276 1.02,2.875 1.216,4.647 -0.43,0.012 -0.844,0.089 -1.233,0.22 -0.18,-1.69 -0.577,-3.185 -1.116,-4.338C13.76,3.988 12.821,3.25 12,3.25s-1.759,0.738 -2.527,2.385C8.728,7.23 8.25,9.48 8.25,12q0,0.32 0.012,0.633a58,58 0,0 0,4.678 0.11,4.3 4.3,0 0,0 -0.19,1.251 60,60 0,0 1,-4.408 -0.102c0.172,1.745 0.578,3.29 1.13,4.473 0.394,0.844 0.834,1.448 1.278,1.837q0,0.217 0.018,0.43c0.05,0.577 -0.404,1.249 -0.97,1.122C5.336,20.75 2,16.766 2,12 2,6.477 6.477,2 12,2M3.331,13.182a8.76,8.76 0,0 0,5.812 7.088,8.3 8.3,0 0,1 -0.803,-1.376c-0.646,-1.383 -1.09,-3.147 -1.26,-5.098a32,32 0,0 1,-1.8 -0.205c-0.772,-0.113 -1.438,-0.25 -1.949,-0.41ZM9.143,3.729a8.75,8.75 0,0 0,-5.888 8.081c0.067,0.036 0.167,0.084 0.311,0.134 0.42,0.146 1.062,0.287 1.896,0.41 0.466,0.068 0.984,0.127 1.545,0.18A21,21 0,0 1,7 12c0,-2.66 0.502,-5.097 1.34,-6.894 0.232,-0.498 0.501,-0.964 0.803,-1.377"
+ android:fillType="evenOdd">
+
+
+
+
+
+
+
+ android:pathData="M18.75,14a1.75,1.75 0,1 0,-3.5 0v2.875a1.75,1.75 0,1 0,3.5 0zM20,16.875a3,3 0,1 1,-6 0V14a3,3 0,1 1,6 0z"
+ android:fillColor="#888"/>
-
+ android:pathData="M20.75,17.546a0.296,0.296 0,0 0,-0.296 -0.296h-6.908a0.296,0.296 0,0 0,-0.296 0.296v2.908c0,0.163 0.133,0.296 0.296,0.296h6.908a0.296,0.296 0,0 0,0.296 -0.296zM22,20.454c0,0.854 -0.692,1.546 -1.546,1.546h-6.908A1.546,1.546 0,0 1,12 20.454v-2.908c0,-0.854 0.692,-1.546 1.546,-1.546h6.908c0.854,0 1.546,0.692 1.546,1.546z"
+ android:fillColor="#666"/>
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-api/src/main/java/com/duckduckgo/subscriptions/api/SubscriptionScreens.kt b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/SubscriptionScreens.kt
index c6c0f9af250d..245c240fac3b 100644
--- a/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/SubscriptionScreens.kt
+++ b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/SubscriptionScreens.kt
@@ -19,5 +19,7 @@ package com.duckduckgo.subscriptions.api
import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams
sealed class SubscriptionScreens {
+ data object SubscriptionsSettingsScreenWithEmptyParams : ActivityParams
data object SubscriptionScreenNoParams : ActivityParams
+ data class RestoreSubscriptionScreenWithParams(val isOriginWeb: Boolean = true) : ActivityParams
}
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 6d2d07976cc5..09f66815d04e 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
@@ -49,6 +49,8 @@ interface Subscriptions {
*/
suspend fun isEligible(): Boolean
+ fun getSubscriptionStatusFlow(): Flow
+
/**
* @return `SubscriptionStatus` with the current subscription status
*/
@@ -88,6 +90,7 @@ enum class Product(val value: String) {
ITR("Identity Theft Restoration"),
ROW_ITR("Global Identity Theft Restoration"),
PIR("Data Broker Protection"),
+ DuckAiPlus("Duck.ai"),
}
enum class SubscriptionStatus(val statusName: String) {
diff --git a/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt b/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt
index 8bd21d9fc006..3527248b269a 100644
--- a/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt
+++ b/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt
@@ -38,6 +38,8 @@ class SubscriptionsDummy @Inject constructor() : Subscriptions {
override suspend fun isEligible(): Boolean = false
+ override fun getSubscriptionStatusFlow(): Flow = flowOf(UNKNOWN)
+
override suspend fun getSubscriptionStatus(): SubscriptionStatus = UNKNOWN
override suspend fun getAvailableProducts(): Set = emptySet()
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 6925fd2a5816..c3beae4e99b8 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
@@ -82,6 +82,10 @@ class RealSubscriptions @Inject constructor(
return isActive || (isEligible && supportsEncryption)
}
+ override fun getSubscriptionStatusFlow(): Flow {
+ return subscriptionsManager.subscriptionStatus
+ }
+
override suspend fun getSubscriptionStatus(): SubscriptionStatus {
return subscriptionsManager.subscriptionStatus()
}
@@ -168,6 +172,14 @@ 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
+
/**
* Android supports v2 token, but still relies on old v1 subscription messaging.
* We are introducing new JS messaging. Use this flag as kill-switch if necessary.
@@ -190,6 +202,17 @@ interface PrivacyProFeature {
*/
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun authApiV2JwksCache(): Toggle
+
+ /**
+ * As part of Duck.ai we are adding new supported JS messages.
+ * This is enabled by default, but can be disabled if necessary.
+ * FF only controls native messaging (enabled/disabled).
+ */
+ @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/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/feedback/FeedbackSubCategoryProvider.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/FeedbackSubCategoryProvider.kt
index 77bd55efd1d3..f63280225770 100644
--- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/FeedbackSubCategoryProvider.kt
+++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/FeedbackSubCategoryProvider.kt
@@ -18,6 +18,7 @@ package com.duckduckgo.subscriptions.impl.feedback
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.subscriptions.impl.R
+import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.DUCK_AI
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.ITR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.PIR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.SUBS_AND_PAYMENTS
@@ -37,6 +38,7 @@ class RealFeedbackSubCategoryProvider @Inject constructor() : FeedbackSubCategor
SUBS_AND_PAYMENTS -> getSubsSubCategories()
PIR -> getPirSubCategories()
ITR -> getItrSubCategories()
+ DUCK_AI -> getDuckAiSubCategories()
}
}
@@ -76,4 +78,12 @@ class RealFeedbackSubCategoryProvider @Inject constructor() : FeedbackSubCategor
R.string.feedbackSubCategoryItrOther to SubscriptionFeedbackItrSubCategory.OTHER,
)
}
+
+ private fun getDuckAiSubCategories(): Map {
+ return mapOf(
+ R.string.feedbackSubCategoryDuckAiSubscriberModels to SubscriptionFeedbackDuckAiSubCategory.ACCESS_SUBSCRIPTION_MODELS,
+ R.string.feedbackSubCategoryDuckAiLoginThirdPartyBrowser to SubscriptionFeedbackDuckAiSubCategory.LOGIN_THIRD_PARTY_BROWSER,
+ R.string.feedbackSubCategoryDuckAiOther to SubscriptionFeedbackDuckAiSubCategory.OTHER,
+ )
+ }
}
diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackCategoryFragment.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackCategoryFragment.kt
index aec433e71a6f..6d09fcc4583f 100644
--- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackCategoryFragment.kt
+++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackCategoryFragment.kt
@@ -23,18 +23,24 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStarted
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.viewbinding.viewBinding
+import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.FragmentScope
+import com.duckduckgo.subscriptions.api.Product
+import com.duckduckgo.subscriptions.impl.PrivacyProFeature
import com.duckduckgo.subscriptions.impl.R
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US
import com.duckduckgo.subscriptions.impl.databinding.ContentFeedbackCategoryBinding
+import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.DUCK_AI
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.ITR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.PIR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.SUBS_AND_PAYMENTS
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.VPN
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
+import com.duckduckgo.subscriptions.impl.repository.toProductList
import javax.inject.Inject
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
@InjectWith(FragmentScope::class)
class SubscriptionFeedbackCategoryFragment : SubscriptionFeedbackFragment(R.layout.content_feedback_category) {
@@ -43,6 +49,12 @@ class SubscriptionFeedbackCategoryFragment : SubscriptionFeedbackFragment(R.layo
@Inject
lateinit var authRepository: AuthRepository
+ @Inject
+ lateinit var subscriptionFeature: PrivacyProFeature
+
+ @Inject
+ lateinit var dispatcherProvider: DispatcherProvider
+
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
@@ -62,6 +74,16 @@ class SubscriptionFeedbackCategoryFragment : SubscriptionFeedbackFragment(R.layo
binding.categoryPir.setOnClickListener {
listener.onUserClickedCategory(PIR)
}
+ binding.categoryDuckAi.setOnClickListener {
+ listener.onUserClickedCategory(DUCK_AI)
+ }
+
+ lifecycleScope.launch {
+ val duckAiAvailable = isDuckAiAvailable()
+ withStarted {
+ binding.categoryDuckAi.isVisible = duckAiAvailable
+ }
+ }
lifecycleScope.launch {
val pirAvailable = isPirCategoryAvailable()
@@ -76,6 +98,11 @@ class SubscriptionFeedbackCategoryFragment : SubscriptionFeedbackFragment(R.layo
return subscription.productId in listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)
}
+ private suspend fun isDuckAiAvailable(): Boolean = withContext(dispatcherProvider.io()) {
+ val isDuckAiEnabled = subscriptionFeature.duckAiPlus().isEnabled()
+ isDuckAiEnabled && authRepository.getEntitlements().toProductList().any { it == Product.DuckAiPlus }
+ }
+
interface Listener {
fun onUserClickedCategory(category: SubscriptionFeedbackCategory)
}
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/feedback/SubscriptionFeedbackParams.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackParams.kt
index 344b3ffe1eea..c760cd42b522 100644
--- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackParams.kt
+++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackParams.kt
@@ -22,10 +22,14 @@ import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeed
import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.UNKNOWN
import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.VPN_EXCLUDED_APPS
import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.VPN_MANAGEMENT
+import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.DUCK_AI
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.ITR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.PIR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.SUBS_AND_PAYMENTS
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.VPN
+import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackDuckAiSubCategory.ACCESS_SUBSCRIPTION_MODELS
+import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackDuckAiSubCategory.LOGIN_THIRD_PARTY_BROWSER
+import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackDuckAiSubCategory.OTHER
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackItrSubCategory.ACCESS_CODE_ISSUE
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackItrSubCategory.CANT_CONTACT_ADVISOR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackItrSubCategory.UNHELPFUL
@@ -54,6 +58,7 @@ enum class SubscriptionFeedbackCategory {
VPN,
PIR,
ITR,
+ DUCK_AI,
}
interface SubscriptionFeedbackSubCategory
@@ -86,6 +91,12 @@ enum class SubscriptionFeedbackItrSubCategory : SubscriptionFeedbackSubCategory
OTHER,
}
+enum class SubscriptionFeedbackDuckAiSubCategory : SubscriptionFeedbackSubCategory {
+ ACCESS_SUBSCRIPTION_MODELS,
+ LOGIN_THIRD_PARTY_BROWSER,
+ OTHER,
+}
+
internal fun PrivacyProFeedbackSource.asParams(): String {
return when (this) {
DDG_SETTINGS -> "settings"
@@ -110,6 +121,7 @@ internal fun SubscriptionFeedbackCategory.asParams(): String {
VPN -> "vpn"
PIR -> "pir"
ITR -> "itr"
+ DUCK_AI -> "duckAi"
}
}
@@ -119,6 +131,7 @@ internal fun SubscriptionFeedbackSubCategory.asParams(): String {
is SubscriptionFeedbackSubsSubCategory -> this.asParams()
is SubscriptionFeedbackPirSubCategory -> this.asParams()
is SubscriptionFeedbackItrSubCategory -> this.asParams()
+ is SubscriptionFeedbackDuckAiSubCategory -> this.asParams()
else -> "unknown"
}
}
@@ -159,3 +172,11 @@ internal fun SubscriptionFeedbackItrSubCategory.asParams(): String {
SubscriptionFeedbackItrSubCategory.OTHER -> "somethingElse"
}
}
+
+internal fun SubscriptionFeedbackDuckAiSubCategory.asParams(): String {
+ return when (this) {
+ ACCESS_SUBSCRIPTION_MODELS -> "accessSubscriptionModels"
+ LOGIN_THIRD_PARTY_BROWSER -> "loginThirdPartyBrowser"
+ OTHER -> "somethingElse"
+ }
+}
diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModel.kt
index f5cbe3743b9c..847930b18e0b 100644
--- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModel.kt
+++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModel.kt
@@ -27,6 +27,7 @@ import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeed
import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.VPN_EXCLUDED_APPS
import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.VPN_MANAGEMENT
import com.duckduckgo.subscriptions.impl.R
+import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.DUCK_AI
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.ITR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.PIR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.SUBS_AND_PAYMENTS
@@ -103,11 +104,6 @@ class SubscriptionFeedbackViewModel @Inject constructor(
REPORT_PROBLEM -> {
val source = newMetadata.source
when (source) {
- SUBSCRIPTION_SETTINGS -> {
- newMetadata = newMetadata.copy(category = SUBS_AND_PAYMENTS)
- FeedbackSubCategory(newMetadata.category!!.asTitle())
- }
-
VPN_MANAGEMENT, VPN_EXCLUDED_APPS -> {
newMetadata = newMetadata.copy(category = VPN)
FeedbackSubCategory(newMetadata.category!!.asTitle())
@@ -427,6 +423,7 @@ class SubscriptionFeedbackViewModel @Inject constructor(
VPN -> R.string.feedbackCategoryVpn
PIR -> R.string.feedbackCategoryPir
ITR -> R.string.feedbackCategoryItr
+ DUCK_AI -> R.string.feedbackCategoryDuckAi
}
}
@@ -477,6 +474,14 @@ class SubscriptionFeedbackViewModel @Inject constructor(
}
}
+ is SubscriptionFeedbackDuckAiSubCategory -> {
+ when (this) {
+ SubscriptionFeedbackDuckAiSubCategory.ACCESS_SUBSCRIPTION_MODELS -> R.string.feedbackSubCategoryDuckAiSubscriberModels
+ SubscriptionFeedbackDuckAiSubCategory.LOGIN_THIRD_PARTY_BROWSER -> R.string.feedbackSubCategoryDuckAiLoginThirdPartyBrowser
+ SubscriptionFeedbackDuckAiSubCategory.OTHER -> R.string.feedbackSubCategoryDuckAiOther
+ }
+ }
+
else -> {
-1
}
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 6c5f5c7b027a..0a3ad3527a14 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
@@ -406,8 +406,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/messaging/SubscriptionsContentScopeJsMessageHandler.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsContentScopeJsMessageHandler.kt
index cc4d07760bba..9ad5ffdf0075 100644
--- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsContentScopeJsMessageHandler.kt
+++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsContentScopeJsMessageHandler.kt
@@ -41,6 +41,11 @@ class SubscriptionsContentScopeJsMessageHandler @Inject constructor() : ContentS
override val methods: List = listOf(
"handshake",
"subscriptionDetails",
+ "getAuthAccessToken",
+ "getFeatureConfig",
+ "backToSettings",
+ "openSubscriptionActivation",
+ "openSubscriptionPurchase",
)
}
}
diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsJSHelper.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsJSHelper.kt
index 57b77da264d3..b55cb7549208 100644
--- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsJSHelper.kt
+++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsJSHelper.kt
@@ -16,18 +16,24 @@
package com.duckduckgo.subscriptions.impl.messaging
+import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.js.messaging.api.JsCallbackData
import com.duckduckgo.subscriptions.api.SubscriptionsJSHelper
+import com.duckduckgo.subscriptions.impl.AccessTokenResult
+import com.duckduckgo.subscriptions.impl.PrivacyProFeature
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
+import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
@ContributesBinding(AppScope::class)
class RealSubscriptionsJSHelper @Inject constructor(
private val subscriptionsManager: SubscriptionsManager,
+ private val privacyProFeature: PrivacyProFeature,
+ private val dispatcherProvider: DispatcherProvider,
) : SubscriptionsJSHelper {
override suspend fun processJsCallbackMessage(
@@ -35,23 +41,54 @@ class RealSubscriptionsJSHelper @Inject constructor(
method: String,
id: String?,
data: JSONObject?,
- ): JsCallbackData? = when (method) {
- METHOD_HANDSHAKE -> id?.let {
- val jsonPayload = JSONObject().apply {
- put(AVAILABLE_MESSAGES, JSONArray().put(SUBSCRIPTION_DETAILS))
- put(PLATFORM, ANDROID)
+ ): JsCallbackData? = withContext(dispatcherProvider.io()) {
+ when (method) {
+ METHOD_HANDSHAKE -> id?.let {
+ val availableMethods = if (privacyProFeature.duckAISubscriptionMessaging().isEnabled()) {
+ JSONArray().apply {
+ put(SUBSCRIPTION_DETAILS)
+ put(GET_AUTH_ACCESS_TOKEN)
+ put(GET_FEATURE_CONFIG)
+ put(AUTH_UPDATE)
+ }
+ } else {
+ JSONArray().apply {
+ put(SUBSCRIPTION_DETAILS)
+ }
+ }
+ val jsonPayload = JSONObject().apply {
+ put(
+ AVAILABLE_MESSAGES,
+ availableMethods,
+ )
+ put(PLATFORM, ANDROID)
+ }
+ return@withContext JsCallbackData(jsonPayload, featureName, method, id)
}
- return JsCallbackData(jsonPayload, featureName, method, id)
- }
- METHOD_SUBSCRIPTION_DETAILS -> id?.let {
- getSubscriptionDetailsData(featureName, method, it)
- }
+ METHOD_SUBSCRIPTION_DETAILS -> id?.let {
+ getSubscriptionDetailsData(featureName, method, it)
+ }
+
+ METHOD_GET_AUTH_ACCESS_TOKEN -> id?.let {
+ if (privacyProFeature.duckAISubscriptionMessaging().isEnabled().not()) return@withContext null
+ getAuthAccessTokenData(featureName, method, it)
+ }
+
+ METHOD_GET_FEATURE_CONFIG -> id?.let {
+ if (privacyProFeature.duckAISubscriptionMessaging().isEnabled().not()) return@withContext null
+ getFeatureConfigData(featureName, method, it)
+ }
- else -> null
+ else -> null
+ }
}
- private suspend fun getSubscriptionDetailsData(featureName: String, method: String, id: String): JsCallbackData {
+ private suspend fun getSubscriptionDetailsData(
+ featureName: String,
+ method: String,
+ id: String,
+ ): JsCallbackData {
val jsonPayload = subscriptionsManager.getSubscription()?.let { userSubscription ->
JSONObject().apply {
put(IS_SUBSCRIBED, userSubscription.isActive())
@@ -68,11 +105,44 @@ class RealSubscriptionsJSHelper @Inject constructor(
return JsCallbackData(jsonPayload, featureName, method, id)
}
+ private suspend fun getAuthAccessTokenData(
+ featureName: String,
+ method: String,
+ id: String,
+ ): JsCallbackData {
+ val jsonPayload = when (val result = subscriptionsManager.getAccessToken()) {
+ is AccessTokenResult.Success -> JSONObject().apply {
+ put(ACCESS_TOKEN, result.accessToken)
+ }
+
+ is AccessTokenResult.Failure -> JSONObject()
+ }
+
+ return JsCallbackData(jsonPayload, featureName, method, id)
+ }
+
+ private suspend fun getFeatureConfigData(
+ featureName: String,
+ method: String,
+ id: String,
+ ): JsCallbackData {
+ val jsonPayload = JSONObject().apply {
+ put(USE_PAID_DUCK_AI, privacyProFeature.duckAiPlus().isEnabled())
+ }
+
+ return JsCallbackData(jsonPayload, featureName, method, id)
+ }
+
companion object {
private const val METHOD_HANDSHAKE = "handshake"
private const val METHOD_SUBSCRIPTION_DETAILS = "subscriptionDetails"
+ private const val METHOD_GET_AUTH_ACCESS_TOKEN = "getAuthAccessToken"
+ private const val METHOD_GET_FEATURE_CONFIG = "getFeatureConfig"
private const val AVAILABLE_MESSAGES = "availableMessages"
private const val SUBSCRIPTION_DETAILS = "subscriptionDetails"
+ private const val GET_AUTH_ACCESS_TOKEN = "getAuthAccessToken"
+ private const val AUTH_UPDATE = "authUpdate"
+ private const val GET_FEATURE_CONFIG = "getFeatureConfig"
private const val PLATFORM = "platform"
private const val ANDROID = "android"
private const val IS_SUBSCRIBED = "isSubscribed"
@@ -81,5 +151,7 @@ class RealSubscriptionsJSHelper @Inject constructor(
private const val EXPIRES_OR_RENEWS_AT = "expiresOrRenewsAt"
private const val PAYMENT_PLATFORM = "paymentPlatform"
private const val STATUS = "status"
+ private const val ACCESS_TOKEN = "accessToken"
+ private const val USE_PAID_DUCK_AI = "usePaidDuckAi"
}
}
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/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 d2ebc1c70d9f..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
@@ -34,6 +34,8 @@ import com.duckduckgo.common.utils.ViewViewModelFactory
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.mobile.android.R as CommonR
import com.duckduckgo.navigation.api.GlobalActivityStarter
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.RestoreSubscriptionScreenWithParams
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionsSettingsScreenWithEmptyParams
import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE
import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED
import com.duckduckgo.subscriptions.api.SubscriptionStatus.GRACE_PERIOD
@@ -50,8 +52,6 @@ import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Comm
import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.ViewState
import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.ViewState.SubscriptionRegion.ROW
import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.ViewState.SubscriptionRegion.US
-import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionActivity.Companion.RestoreSubscriptionScreenWithParams
-import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsActivity.Companion.SubscriptionsSettingsScreenWithEmptyParams
import com.duckduckgo.subscriptions.impl.ui.SubscriptionsWebViewActivityWithParams
import dagger.android.support.AndroidSupportInjection
import javax.inject.Inject
@@ -167,20 +167,13 @@ class ProSettingView @JvmOverloads constructor(
}
else -> {
with(binding) {
- subscriptionBuy.setPrimaryText(context.getString(R.string.subscriptionSettingSubscribe))
- subscriptionBuy.setSecondaryText(
- when (viewState.region) {
- ROW -> context.getString(R.string.subscriptionSettingSubscribeSubtitleRow)
- US -> context.getString(R.string.subscriptionSettingSubscribeSubtitle)
- else -> ""
- },
- )
- subscriptionGet.setText(
- when (viewState.freeTrialEligible) {
- true -> R.string.subscriptionSettingTryFreeTrial
- false -> R.string.subscriptionSettingGet
- },
- )
+ if (viewState.duckAiEnabled) {
+ subscriptionBuy.setPrimaryText(context.getString(R.string.subscriptionSettingSubscribeSecure))
+ } else {
+ subscriptionBuy.setPrimaryText(context.getString(R.string.subscriptionSettingSubscribe))
+ }
+ subscriptionBuy.setSecondaryText(getSubscriptionSecondaryText(viewState))
+ subscriptionGet.setText(getActionButtonText(viewState))
subscriptionBuyContainer.isVisible = true
subscriptionRestoreContainer.isVisible = true
@@ -191,6 +184,38 @@ 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)
+ US -> context.getString(R.string.subscriptionSettingSubscribeWithDuckAiSubtitle)
+ else -> ""
+ }
+ } else {
+ when (viewState.region) {
+ ROW -> context.getString(R.string.subscriptionSettingSubscribeSubtitleRow)
+ US -> context.getString(R.string.subscriptionSettingSubscribeSubtitle)
+ else -> ""
+ }
+ }
+
private fun processCommands(command: Command) {
when (command) {
is OpenSettings -> {
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 1111bae34e32..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
@@ -22,9 +22,13 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW
@@ -46,12 +50,16 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
+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,
+ private val dispatcherProvider: DispatcherProvider,
) : ViewModel(), DefaultLifecycleObserver {
sealed class Command {
@@ -65,6 +73,9 @@ 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,
) {
enum class SubscriptionRegion { US, ROW }
@@ -92,19 +103,31 @@ class ProSettingViewModel @Inject constructor(
subscriptionsManager.subscriptionStatus
.distinctUntilChanged()
.onEach { subscriptionStatus ->
- val offer = subscriptionsManager.getSubscriptionOffer().firstOrNull()
- val region = when (offer?.planId) {
- MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW -> SubscriptionRegion.ROW
- MONTHLY_PLAN_US, YEARLY_PLAN_US -> SubscriptionRegion.US
- else -> null
+ withContext(dispatcherProvider.io()) {
+ val offer = subscriptionsManager.getSubscriptionOffer().firstOrNull()
+ val region = when (offer?.planId) {
+ MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW -> SubscriptionRegion.ROW
+ MONTHLY_PLAN_US, YEARLY_PLAN_US -> SubscriptionRegion.US
+ else -> null
+ }
+
+ val duckAiEnabled = privacyProFeature.duckAiPlus().isEnabled()
+ 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(),
+ ),
+ )
}
- _viewState.emit(
- viewState.value.copy(
- status = subscriptionStatus,
- region = region,
- freeTrialEligible = subscriptionsManager.isFreeTrialEligible(),
- ),
- )
}.launchIn(viewModelScope)
}
diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionActivity.kt
index dcc7f2bd9a82..26fa34fff1e0 100644
--- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionActivity.kt
+++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionActivity.kt
@@ -30,12 +30,13 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.navigation.api.getActivityParams
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.RestoreSubscriptionScreenWithParams
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionsSettingsScreenWithEmptyParams
import com.duckduckgo.subscriptions.impl.R.string
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ACTIVATE_URL
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BUY_URL
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.WELCOME_URL
import com.duckduckgo.subscriptions.impl.databinding.ActivityRestoreSubscriptionBinding
-import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionActivity.Companion.RestoreSubscriptionScreenWithParams
import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command
import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Error
import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.FinishAndGoToOnboarding
@@ -43,7 +44,6 @@ import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command
import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.RestoreFromEmail
import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.SubscriptionNotFound
import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Success
-import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsActivity.Companion.SubscriptionsSettingsScreenWithEmptyParams
import javax.inject.Inject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -193,7 +193,4 @@ class RestoreSubscriptionActivity : DuckDuckGoActivity() {
is FinishAndGoToSubscriptionSettings -> finishAndGoToSubscriptionSettings()
}
}
- companion object {
- data class RestoreSubscriptionScreenWithParams(val isOriginWeb: Boolean = true) : GlobalActivityStarter.ActivityParams
- }
}
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 4cf5875f735f..e0800d08cfb7 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
@@ -39,6 +39,7 @@ 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.SubscriptionScreens.SubscriptionsSettingsScreenWithEmptyParams
import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE
import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED
import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE
@@ -50,7 +51,6 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.FAQS_URL
import com.duckduckgo.subscriptions.impl.databinding.ActivitySubscriptionSettingsBinding
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.ui.ChangePlanActivity.Companion.ChangePlanScreenWithEmptyParams
-import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsActivity.Companion.SubscriptionsSettingsScreenWithEmptyParams
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.FinishSignOut
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.GoToActivationScreen
@@ -334,7 +334,5 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() {
const val MANAGE_URL = "https://duckduckgo.com/subscriptions/manage"
const val LEARN_MORE_URL = "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/adding-email"
const val PRIVACY_POLICY_URL = "https://duckduckgo.com/pro/privacy-terms"
-
- data object SubscriptionsSettingsScreenWithEmptyParams : GlobalActivityStarter.ActivityParams
}
}
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 2dcec69379bc..ed99e75c9c93 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
@@ -66,6 +67,7 @@ import com.duckduckgo.mobile.android.R
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams
import com.duckduckgo.navigation.api.getActivityParams
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.RestoreSubscriptionScreenWithParams
import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionScreenNoParams
import com.duckduckgo.subscriptions.impl.R.string
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants
@@ -74,10 +76,10 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BUY_URL
import com.duckduckgo.subscriptions.impl.databinding.ActivitySubscriptionsWebviewBinding
import com.duckduckgo.subscriptions.impl.pir.PirActivity.Companion.PirScreenWithEmptyParams
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
-import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionActivity.Companion.RestoreSubscriptionScreenWithParams
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()
@@ -421,6 +426,7 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
is GoToITR -> goToITR()
is GoToPIR -> goToPIR()
is GoToNetP -> goToNetP(command.activityParams)
+ is GoToDuckAI -> goToDuckAI()
Reload -> binding.webview.reload()
}
}
@@ -446,6 +452,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/main/res/drawable/ic_identity_blocked_pir_grayscale_color_24.xml b/subscriptions/subscriptions-impl/src/main/res/drawable/ic_identity_blocked_pir_grayscale_color_24.xml
index f212a769e890..bde360974567 100644
--- a/subscriptions/subscriptions-impl/src/main/res/drawable/ic_identity_blocked_pir_grayscale_color_24.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/drawable/ic_identity_blocked_pir_grayscale_color_24.xml
@@ -5,10 +5,10 @@
android:viewportWidth="24"
android:viewportHeight="24">
+ android:pathData="M11.06,15.056a6.25,6.25 0,0 0,0.945 5.7H12a8.72,8.72 0,0 1,-6.217 -2.591c1.182,-1.674 3.082,-2.846 5.277,-3.11ZM12,6.875a3.125,3.125 0,0 1,2.923 4.229,6.26 6.26,0 0,0 -2.823,2.018l-0.1,0.003a3.125,3.125 0,1 1,0 -6.25">
+ android:pathData="M12,2c4.972,0 9.097,3.629 9.87,8.383 0.07,0.429 0.11,0.871 0.123,1.316 0.015,0.537 -0.67,0.757 -1.089,0.42l-0.155,-0.12a8.75,8.75 0,1 0,-15.644 5.39,8.6 8.6,0 0,1 2.633,-2.283l0.12,-0.065a8.7,8.7 0,0 1,3.65 -1.028q-0.335,0.615 -0.527,1.304a7.4,7.4 0,0 0,-2.631 0.88,7.4 7.4,0 0,0 -2.388,2.136A8.72,8.72 0,0 0,12 20.75l0.12,0.154c0.336,0.419 0.116,1.104 -0.421,1.09a10,10 0,0 1,-1.199 -0.106C5.689,21.164 2,17.013 2,12 2,6.477 6.477,2 12,2m0,4a4,4 0,0 1,3.91 4.847,6.2 6.2,0 0,0 -1.491,0.46A2.75,2.75 0,1 0,12 12.75c0.157,0.001 0.31,-0.016 0.459,-0.041q-0.542,0.571 -0.928,1.264A4,4 0,0 1,12 6">
-
+
+ android:pathData="M21.375,17a4.375,4.375 0,1 1,-8.75 0,4.375 4.375,0 0,1 8.75,0"
+ android:fillColor="#AAA"/>
+ android:pathData="M17,12a5,5 0,1 1,0 10,5 5,0 0,1 0,-10m0,1.25a3.75,3.75 0,1 0,0 7.5,3.75 3.75,0 0,0 0,-7.5">
-
-
+
+
diff --git a/subscriptions/subscriptions-impl/src/main/res/drawable/ic_identity_theft_restoration_grayscale_color_24.xml b/subscriptions/subscriptions-impl/src/main/res/drawable/ic_identity_theft_restoration_grayscale_color_24.xml
index 11746bf3e00f..409cf13630bc 100644
--- a/subscriptions/subscriptions-impl/src/main/res/drawable/ic_identity_theft_restoration_grayscale_color_24.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/drawable/ic_identity_theft_restoration_grayscale_color_24.xml
@@ -5,26 +5,26 @@
android:viewportWidth="24"
android:viewportHeight="24">
+ android:pathData="M17.257,3.007A5,5 0,0 1,22 8v7.072c-0.228,-0.09 -0.442,-0.14 -0.59,-0.177l-0.108,-0.026a11,11 0,0 0,-0.552 -0.098V10.5H3.25V16A3.75,3.75 0,0 0,7 19.75h2.854A2.74,2.74 0,0 0,9.798 21H7a5,5 0,0 1,-5 -5V8a5,5 0,0 1,5 -5h10zM7,4.25A3.75,3.75 0,0 0,3.284 7.5h17.432a3.75,3.75 0,0 0,-3.523 -3.245L17,4.25z">
-
+
diff --git a/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_category.xml b/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_category.xml
index c76f907a188d..10a2ed16eab6 100644
--- a/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_category.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/layout/content_feedback_category.xml
@@ -41,6 +41,13 @@
android:layout_height="wrap_content"
app:primaryText="@string/feedbackCategoryVpn" />
+
+
+ android:layout_height="wrap_content"/>
\ No newline at end of file
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-bg/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-bg/strings-subscriptions.xml
index 9252566c5b66..0487991579bd 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-bg/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-bg/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Настройки на абонамент
Защитете връзката и самоличността си с Privacy Pro
- Включва нашите функции VPN, Personal Information Removal и Identity Theft Restoration.
- Включва нашите функции VPN и Identity Theft Restoration.
+
+ Абонатите получават нашите услуги за VPN, Personal Information Removal и Identity Theft Restoration.
+ Абонатите получават нашите услуги за VPN и Identity Theft Restoration.
+
Вземете Privacy Pro
Изпробвайте Privacy Pro безплатно
Имам абонамент
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-cs/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-cs/strings-subscriptions.xml
index edceaec6bef0..df3e49697d53 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-cs/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-cs/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Nastavení předplatného
Chraň své připojení a identitu se službou Privacy Pro
- Zahrnuje naše funkce VPN, Personal Information Removal a Identity Theft Restoration.
- Zahrnuje naši VPN a službu Identity Theft Restoration.
+
+ Předplatitelé získají naši VPN a služby Personal Information Removal a Identity Theft Restoration.
+ Předplatitelé získají naši VPN a službu Identity Theft Restoration.
+
Pořiď si službu Privacy Pro
Vyzkoušej zdarma službu Privacy Pro
Mám předplatné
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-da/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-da/strings-subscriptions.xml
index eb147a4dcd35..7afa24069aeb 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-da/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-da/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Abonnementsindstillinger
Beskyt din forbindelse og identitet med Privacy Pro
- Inkluderer vores VPN, Personal Information Removal og Identity Theft Restoration.
- Inkluderer vores VPN og Identity Theft Restoration.
+
+ Abonnenter får vores VPN, Personal Information Removal og Identity Theft Restoration.
+ Abonnenter får vores VPN og Identity Theft Restoration.
+
Få Privacy Pro
Prøv Privacy Pro gratis
Jeg har et abonnement
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-de/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-de/strings-subscriptions.xml
index d5be98a85886..8883d8e46aec 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-de/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-de/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Abonnementeinstellungen
Schütze deine Verbindung und Identität mit Privacy Pro
- Dazu gehört unsere VPN, Personal Information Removal und Identity Theft Restoration.
- Dazu gehört unser VPN und Identity Theft Restoration.
+
+ Abonnenten erhalten unser VPN, Personal Information Removal und Identity Theft Restoration.
+ Abonnenten erhalten unser VPN und Identity Theft Restoration.
+
Privacy Pro kaufen
Privacy Pro kostenlos testen
Ich habe ein Abonnement
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-el/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-el/strings-subscriptions.xml
index 3c971eb37085..4d309bedb7d2 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-el/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-el/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Ρυθμίσεις συνδρομής
Προστατέψτε τη σύνδεση και την ταυτότητά σας με το Privacy Pro
- Περιλαμβάνει το VPN μας και τα Personal Information Removal και Identity Theft Restoration.
- Περιλαμβάνει το VPN μας και τo Identity Theft Restoration.
+
+ Οι συνδρομητές αποκτούν το VPN, το Personal Information Removal και το Identity Theft Restoration.
+ Οι συνδρομητές λαμβάνουν το VPN μας και τo Identity Theft Restoration.
+
Απόκτησε το Privacy Pro
Δοκιμάστε το Privacy Pro δωρεάν
Έχω συνδρομή
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-es/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-es/strings-subscriptions.xml
index 5db84d7f90b7..a3d1dda1c60d 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-es/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-es/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Ajustes de suscripción
Protege tu conexión e identidad con Privacy Pro
- Incluye nuestras VPN, Personal Information Removal, e Identity Theft Restoration.
- Incluye nuestra VPN y Identity Theft Restoration.
+
+ Los suscriptores obtienen nuestra VPN, Personal Information Removal y Identity Theft Restoration.
+ Los suscriptores obtienen nuestra VPN y Identity Theft Restoration.
+
Conseguir Privacy Pro
Prueba Privacy Pro gratis
Tengo una suscripción
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-et/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-et/strings-subscriptions.xml
index d16b0b96eb77..7853fd1fad23 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-et/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-et/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Tellimuse seaded
Kaitse oma ühendust ja identiteeti rakendusega Privacy Pro
- Sisaldab meie VPN-i, rakendusi Personal Information Removal ja Identity Theft Restoration.
- Sisaldab meie VPN-i ja teenust Identity Theft Restoration.
+
+ Tellijad saavad meie VPN-i ja teenused Personal Information Removal ning Identity Theft Restoration.
+ Tellijad saavad meie VPN-i ja teenuse Identity Theft Restoration.
+
Hangi Privacy Pro
Proovi Privacy Pro\'d tasuta
Mul on tellimus
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-fi/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-fi/strings-subscriptions.xml
index 25327ddaba9b..47322b5e9219 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-fi/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-fi/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Tilauksen asetukset
Suojaa yhteytesi ja identiteettisi Privacy Prolla
- Seuraavat sisältyvät: VPN, Personal Information Removal ja Identity Theft Restoration.
- Sisältää: VPN ja Identity Theft Restoration.
+
+ Tilaajat saavat VPN:n, Personal Information Removalin ja Identity Theft Restorationin.
+ Tilaajat saavat VPN:n ja Identity Theft Restorationin.
+
Hanki Privacy Pro
Kokeile Privacy Pro -tilausta ilmaiseksi
Minulla on tilaus
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-fr/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-fr/strings-subscriptions.xml
index be8f77407922..9b487b3ac718 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-fr/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-fr/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Paramètres d\'abonnement
Protégez votre connexion et votre identité avec Privacy Pro
- Comprend notre VPN, Personal Information Removal et Identity Theft Restoration.
- Comprend notre VPN et Identity Theft Restoration.
+
+ Les abonnés bénéficient de notre VPN, de Personal Information Removal et d\'Identity Theft Restoration.
+ Les abonnés bénéficient de notre VPN et d\'Identity Theft Restoration.
+
Obtenir Privacy Pro
Essayer Privacy Pro gratuitement
J\'ai un abonnement
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-hr/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-hr/strings-subscriptions.xml
index 4d5779af39c2..1da46239151c 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-hr/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-hr/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Postavke pretplate
Zaštiti svoju vezu i identitet uz Privacy Pro
- Uključuje naš VPN, Personal Information Removal i Identity Theft Restoration.
- Uključuje naš VPN i Identity Theft Restoration (oporavak od krađe identiteta).
+
+ Pretplatnici dobivaju naš VPN te usluge Personal Information Removal (uklanjanje osobnih podataka) i Identity Theft Restoration (oporavak od krađe identiteta).
+ Pretplatnici dobivaju naš VPN i uslugu Identity Theft Restoration (oporavak od krađe identiteta).
+
Nabavi Privacy Pro
Besplatno isprobaj Privacy Pro
Imam pretplatu
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-hu/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-hu/strings-subscriptions.xml
index 3086680eaf7d..42a3e14de715 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-hu/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-hu/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Előfizetési beállítások
Kapcsolat és a személyazonosság védelme a Privacy Pro segítségével
- Tartalmazza a VPN-megoldásunkat, a személyes adatok eltávolítására szolgáló Personal Information Removal és a személyazonosság helyreállításához használható Identity Theft Restoration funkciókat.
- Tartalmazza a VPN-megoldásunkat és a személyazonosság helyreállításához használható Identity Theft Restoration funkciókat.
+
+ Az előfizetők megkapják a VPN-megoldásunkat, a személyes adatok eltávolítására szolgáló Personal Information Removal és a személyazonosság helyreállításához használható Identity Theft Restoration funkciókat.
+ Az előfizetők megkapják a VPN-megoldásunkat és a személyazonosság helyreállításához használható Identity Theft Restoration funkciókat.
+
Privacy Pro előfizetése
Próbáld ki a Privacy Prót ingyen
Van előfizetésem
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-it/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-it/strings-subscriptions.xml
index e82d3fa57d9d..38dd35168630 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-it/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-it/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Impostazioni dell\'abbonamento
Proteggi la tua connessione e la tua identità con Privacy Pro
- Includi VPN, Personal Information Removal e Identity Theft Restoration.
- Include i nostri servizi di VPN e Identity Theft Restoration.
+
+ Gli abbonati ricevono la nostra VPN, Personal Information Removal e Identity Theft Restoration.
+ Gli abbonati ricevono i nostri servizi di VPN e Identity Theft Restoration.
+
Ottieni Privacy Pro
Prova Privacy Pro gratis
Ho un abbonamento
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-lt/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-lt/strings-subscriptions.xml
index 3c92080b59b6..0a8fc6349eeb 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-lt/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-lt/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Prenumeratos nustatymai
Apsaugokite savo ryšį ir tapatybę su „Privacy Pro“.
- Apima mūsų VPN, Asmens duomenų pašalinimą ir Tapatybės vagystės atkūrimą.
- Apima mūsų VPN ir Identity Theft Restoration.
+
+ Prenumeratoriai gauna mūsų VPN, asmeninės informacijos pašalinimo ir tapatybės vagystės atkūrimo paslaugas.
+ Prenumeratoriai gauna mūsų VPN ir tapatybės vagystės atkūrimą.
+
Gaukite „Privacy Pro“
Išbandyk „Privacy Pro“ nemokamai
Turiu prenumeratą
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-lv/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-lv/strings-subscriptions.xml
index 9d7cdfa7116b..32474f7cc1ad 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-lv/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-lv/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Abonementa iestatījumi
Aizsargā savu savienojumu un identitāti ar Privacy Pro
- Ietver mūsu VPN, Personal Information Removal un Identity Theft Restoration.
- Ietver mūsu VPN un Identity Theft Restoration.
+
+ Abonenti saņem mūsu VPN, Personal Information Removal un Identity Theft Restoration.
+ Abonenti saņem mūsu VPN un Identity Theft Restoration.
+
Iegūsti Privacy Pro
Izmēģināt Privacy Pro bez maksas
Man ir abonements
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-nb/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-nb/strings-subscriptions.xml
index 4e1f542e1fee..9fe079a9d8f4 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-nb/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-nb/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Abonnementsinnstillinger
Beskytt tilkoblingen og identiteten din med Privacy Pro
- Inkluderer vår VPN, Personal Information Removal og Identity Theft Restoration.
- Inkluderer vår VPN og Identity Theft Restoration.
+
+ Abonnenter får vår VPN, Personal Information Removal og Identity Theft Restoration.
+ Abonnenter får vår VPN og Identity Theft Restoration.
+
Skaff deg Privacy Pro
Prøv Privacy Pro gratis
Jeg har et abonnement
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-nl/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-nl/strings-subscriptions.xml
index 7434d40891c2..a606b42196fc 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-nl/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-nl/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Abonnementinstellingen
Bescherm je verbinding en identiteit met Privacy Pro
- Inclusief onze VPN, Personal Information Removal en Identity Theft Restoration.
- Inclusief onze VPN en Identity Theft Restoration.
+
+ Abonnees krijgen onze VPN, Personal Information Removal en Identity Theft Restoration.
+ Abonnees krijgen onze VPN en Identity Theft Restoration.
+
Privacy Pro kopen
Probeer Privacy Pro gratis
Ik heb een abonnement
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-pl/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-pl/strings-subscriptions.xml
index f0b16203e99d..888cb02f95d7 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-pl/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-pl/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Ustawienia subskrypcji
Chroń połączenie i tożsamość dzięki subskrypcji Privacy Pro
- Obejmuje usługi VPN, Personal Information Removal oraz Identity Theft Restoration.
- Obejmuje usługi VPN oraz Identity Theft Restoration.
+
+ Subskrybenci otrzymują usługi VPN, Personal Information Removal oraz Identity Theft Restoration.
+ Subskrybenci otrzymują usługi VPN oraz Identity Theft Restoration.
+
Uzyskaj Privacy Pro
Wypróbuj Privacy Pro bezpłatnie
Mam subskrypcję
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-pt/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-pt/strings-subscriptions.xml
index e920cdadcc8b..84f25834abae 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-pt/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-pt/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Definições de Subscrição
Protege a tua ligação e identidade com o Privacy Pro
- Inclui a nossa VPN, a Personal Information Removal e a Identity Theft Restoration.
- Inclui a nossa VPN e a Identity Theft Restoration.
+
+ Os subscritores obtêm a nossa VPN, a Personal Information Removal e a Identity Theft Restoration.
+ Os subscritores obtêm a nossa VPN e a Identity Theft Restoration.
+
Obter Privacy Pro
Experimentar o Privacy Pro gratuitamente
Tenho uma Subscrição
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-ro/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-ro/strings-subscriptions.xml
index d6a774e92faf..f5bfe3912305 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-ro/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-ro/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Setări pentru abonament
Protejează-ți conexiunea și identitatea cu Privacy Pro
- Include VPN, Personal Information Removal și Identity Theft Restoration.
- Include VPN-ul nostru și Identity Theft Restoration.
+
+ Abonații beneficiază de VPN, Personal Information Removal și Identity Theft Restoration.
+ Abonații beneficiază de VPN și Identity Theft Restoration.
+
Obține Privacy Pro
Încearcă Privacy Pro gratuit
Am un abonament
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-ru/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-ru/strings-subscriptions.xml
index 0411b7042209..673d9e4b5ef8 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-ru/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-ru/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Настройки подписки
Тариф Privacy Pro для защиты соединения и личных данных
- Включает услуги: VPN, Personal Information Removal (удаление личной информации) и Identity Theft Restoration (восстановление данных после «кражи личности»).
- Включает услуги: VPN и и Identity Theft Restoration (восстановление данных после «кражи личности»).
+
+ Подписка открывает доступ к VPN, а также функциям удаления личной информации и восстановления после кражи данных.
+ Подписка открывает доступ к VPN и функции восстановления после кражи данных.
+
Перейти на Privacy Pro
Попробуйте Privacy Pro бесплатно
У меня уже есть подписка
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-sk/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-sk/strings-subscriptions.xml
index c015c8f40358..e3fd323ab291 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-sk/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-sk/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Nastavenia predplatného
Chráň si pripojenie a totožnosť pomocou Privacy Pro
- Zahŕňa naše služby VPN, Personal Information Removal a Identity Theft Restoration.
- Zahŕňa našu VPN a Identity Theft Restoration.
+
+ Predplatitelia získajú našu sieť VPN, Personal Information Removal a Identity Theft Restoration.
+ Predplatitelia získajú našu VPN a Identity Theft Restoration.
+
Získaj Privacy Pro
Vyskúšaj Privacy Pro zadarmo
Mám predplatné
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-sl/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-sl/strings-subscriptions.xml
index dc88d9a2ba27..1379d01e8b3e 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-sl/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-sl/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Nastavitve naročnine
Zaščite svojo povezavo in identiteto s Privacy Pro
- Vključuje naše storitve VPN, Personal Information Removal in Identity Theft Restoration.
- Vključuje naš VPN in Identity Theft Restoration.
+
+ Naročnikom so na voljo naš VPN ter zaščiti Personal Information Removal in Identity Theft Restoration.
+ Naročnikom je sta voljo naš VPN in zaščita Identity Theft Restoration.
+
Pridobite Privacy Pro
Preizkusite Privacy Pro brezplačno
Imam naročnino
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-sv/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-sv/strings-subscriptions.xml
index 02c176265d58..b4cab1b183a6 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-sv/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-sv/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Abonnemangsinställningar
Skydda din anslutning och identitet med Privacy Pro
- Det inkluderar vårt VPN, Personal Information Removal och Identity Theft Restoration.
- Inkluderar vårt VPN och Identity Theft Restoration.
+
+ Abonnenter får vårt VPN, Personal Information Removal och Identity Theft Restoration.
+ Abonnenter får vårt VPN och Identity Theft Restoration.
+
Skaffa Privacy Pro
Prova Privacy Pro gratis
Jag har ett abonnemang
diff --git a/subscriptions/subscriptions-impl/src/main/res/values-tr/strings-subscriptions.xml b/subscriptions/subscriptions-impl/src/main/res/values-tr/strings-subscriptions.xml
index 7ffeb4dde6dc..1490e92d02a7 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values-tr/strings-subscriptions.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values-tr/strings-subscriptions.xml
@@ -69,8 +69,10 @@
Abonelik Ayarları
Privacy Pro ile bağlantınızı ve kimliğinizi koruyun
- VPN, Personal Information Removal ve Identity Theft Restoration\'ı içerir.
- VPN ve Identity Theft Restoration hizmetlerimizi içerir.
+
+ Aboneler VPN, Personal Information Removal ve Identity Theft Restoration hizmetlerimizden yararlanır.
+ Aboneler VPN ve Identity Theft Restoration hizmetlerimizden yararlanır.
+
Privacy Pro\'yu Al
Privacy Pro\'yu Ücretsiz Deneyin
Aboneliğim Var
diff --git a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml
index 2ee6dabccd26..21529ea0aecf 100644
--- a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml
+++ b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml
@@ -15,5 +15,18 @@
-->
+ 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..a1b2bf071b43 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,10 @@
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/RealProductSubscriptionManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealProductSubscriptionManagerTest.kt
index c5572a08c51d..89c2185da075 100644
--- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealProductSubscriptionManagerTest.kt
+++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealProductSubscriptionManagerTest.kt
@@ -263,6 +263,8 @@ private class FakeSubscriptions(
override suspend fun isEligible(): Boolean = true
+ override fun getSubscriptionStatusFlow(): Flow = flowOf(subscriptionStatus)
+
override suspend fun getSubscriptionStatus(): SubscriptionStatus = subscriptionStatus
override suspend fun getAvailableProducts(): Set = emptySet()
diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModelTest.kt
index 091dbf50c0de..bf0ca5b4583f 100644
--- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModelTest.kt
+++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/feedback/SubscriptionFeedbackViewModelTest.kt
@@ -7,6 +7,7 @@ import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeed
import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.VPN_EXCLUDED_APPS
import com.duckduckgo.subscriptions.api.PrivacyProUnifiedFeedback.PrivacyProFeedbackSource.VPN_MANAGEMENT
import com.duckduckgo.subscriptions.impl.R
+import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.DUCK_AI
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.ITR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.PIR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.SUBS_AND_PAYMENTS
@@ -90,7 +91,29 @@ class SubscriptionFeedbackViewModelTest {
}
@Test
- fun whenFeedbackIsOpenedFromPproThenShowActionScreenAndEmitImpression() = runTest {
+ fun whenFeedbackIsOpenedFromSettingsThenShowActionScreenAndEmitImpression() = runTest {
+ viewModel.viewState().test {
+ viewModel.allowUserToChooseReportType(source = DDG_SETTINGS)
+
+ expectMostRecentItem().assertViewStateMoveForward(
+ expectedPreviousFragmentState = null,
+ expectedCurrentFragmentState = FeedbackAction,
+ FeedbackMetadata(
+ source = DDG_SETTINGS,
+ ),
+ )
+
+ cancelAndConsumeRemainingEvents()
+ verify(pixelSender).reportPproFeedbackActionsScreenShown(
+ mapOf(
+ "source" to "settings",
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun whenFeedbackIsOpenedFromSubscriptionsThenShowActionScreenAndEmitImpression() = runTest {
viewModel.viewState().test {
viewModel.allowUserToChooseReportType(source = SUBSCRIPTION_SETTINGS)
@@ -270,7 +293,7 @@ class SubscriptionFeedbackViewModelTest {
}
@Test
- fun whenReportProblemIsSelectedViaPproThenShowSubsSubCategoryScreenAndEmitImpression() =
+ fun whenReportProblemIsSelectedViaSubscriptionsThenShowCategoryScreenAndEmitImpression() =
runTest {
viewModel.viewState().test {
viewModel.allowUserToChooseReportType(source = SUBSCRIPTION_SETTINGS)
@@ -278,20 +301,19 @@ class SubscriptionFeedbackViewModelTest {
expectMostRecentItem().assertViewStateMoveForward(
expectedPreviousFragmentState = FeedbackAction,
- expectedCurrentFragmentState = FeedbackSubCategory(R.string.feedbackCategorySubscription),
+ expectedCurrentFragmentState = FeedbackCategory(R.string.feedbackActionReportIssue),
FeedbackMetadata(
source = SUBSCRIPTION_SETTINGS,
reportType = REPORT_PROBLEM,
- category = SUBS_AND_PAYMENTS, // Automatically set category
+ category = null,
),
)
cancelAndConsumeRemainingEvents()
- verify(pixelSender).reportPproFeedbackSubcategoryScreenShown(
+ verify(pixelSender).reportPproFeedbackCategoryScreenShown(
mapOf(
"source" to "ppro",
"reportType" to "reportIssue",
- "category" to "subscription",
),
)
}
@@ -358,6 +380,7 @@ class SubscriptionFeedbackViewModelTest {
assertEquals("vpn", VPN.asParams())
assertEquals("pir", PIR.asParams())
assertEquals("itr", ITR.asParams())
+ assertEquals("duckAi", DUCK_AI.asParams())
}
@Test
@@ -400,6 +423,10 @@ class SubscriptionFeedbackViewModelTest {
)
assertEquals("advisorUnhelpful", SubscriptionFeedbackItrSubCategory.UNHELPFUL.asParams())
assertEquals("somethingElse", SubscriptionFeedbackItrSubCategory.OTHER.asParams())
+
+ assertEquals("accessSubscriptionModels", SubscriptionFeedbackDuckAiSubCategory.ACCESS_SUBSCRIPTION_MODELS.asParams())
+ assertEquals("loginThirdPartyBrowser", SubscriptionFeedbackDuckAiSubCategory.LOGIN_THIRD_PARTY_BROWSER.asParams())
+ assertEquals("somethingElse", SubscriptionFeedbackDuckAiSubCategory.OTHER.asParams())
}
@Test
@@ -437,6 +464,7 @@ class SubscriptionFeedbackViewModelTest {
viewModel.viewState().test {
viewModel.allowUserToChooseReportType(source = SUBSCRIPTION_SETTINGS)
viewModel.onReportTypeSelected(reportType = REPORT_PROBLEM)
+ viewModel.onCategorySelected(category = SUBS_AND_PAYMENTS)
viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.ONE_TIME_PASSWORD)
expectMostRecentItem().assertViewStateMoveForward(
@@ -484,6 +512,34 @@ class SubscriptionFeedbackViewModelTest {
}
}
+ @Test
+ fun whenCategorySelectedIsDuckAithenShowDuckAiSubcategoriesScreenAndImpression() = runTest {
+ viewModel.viewState().test {
+ viewModel.allowUserToChooseReportType(source = DDG_SETTINGS)
+ viewModel.onReportTypeSelected(reportType = REPORT_PROBLEM)
+ viewModel.onCategorySelected(category = DUCK_AI)
+
+ expectMostRecentItem().assertViewStateMoveForward(
+ expectedPreviousFragmentState = FeedbackCategory(R.string.feedbackActionReportIssue),
+ expectedCurrentFragmentState = FeedbackSubCategory(R.string.feedbackCategoryDuckAi),
+ FeedbackMetadata(
+ source = DDG_SETTINGS,
+ reportType = REPORT_PROBLEM,
+ category = DUCK_AI,
+ ),
+ )
+
+ cancelAndConsumeRemainingEvents()
+ verify(pixelSender).reportPproFeedbackSubcategoryScreenShown(
+ mapOf(
+ "source" to "settings",
+ "reportType" to "reportIssue",
+ "category" to "duckAi",
+ ),
+ )
+ }
+ }
+
@Test
fun whenCategorySelectedIsPIRThenShowPIRSubcategoryScreenAndImpression() = runTest {
viewModel.viewState().test {
@@ -615,19 +671,22 @@ class SubscriptionFeedbackViewModelTest {
}
@Test
- fun whenMoveBackFromSubCategoryActionViaPproThenUpdateViewState() = runTest {
+ fun whenMoveBackFromSubCategoryActionViaSubscriptionsThenUpdateViewState() = runTest {
viewModel.viewState().test {
viewModel.allowUserToChooseReportType(source = SUBSCRIPTION_SETTINGS) // Show action
- viewModel.onReportTypeSelected(REPORT_PROBLEM) // Show subcategory
+ viewModel.onReportTypeSelected(REPORT_PROBLEM) // Show categories
+ viewModel.onCategorySelected(category = SUBS_AND_PAYMENTS) // Show subcategories
viewModel.handleBackPress()
- expectMostRecentItem().assertViewStateMoveBack(
- expectedPreviousFragmentState = null,
- expectedCurrentFragmentState = FeedbackAction, // Back to action
+ val expectMostRecentItem = expectMostRecentItem()
+ expectMostRecentItem.assertViewStateMoveBack(
+ expectedPreviousFragmentState = FeedbackAction,
+ expectedCurrentFragmentState = FeedbackCategory(R.string.feedbackActionReportIssue), // Back to category
FeedbackMetadata(
source = SUBSCRIPTION_SETTINGS,
- category = SUBS_AND_PAYMENTS, // Retain category
+ reportType = REPORT_PROBLEM,
+ category = null, // Retain category
),
)
}
@@ -671,6 +730,27 @@ class SubscriptionFeedbackViewModelTest {
}
}
+ @Test
+ fun whenMoveBackFromSubCategoryActionDuckAiCategoryThenUpdateViewState() = runTest {
+ viewModel.viewState().test {
+ viewModel.allowUserToChooseFeedbackType() // Show general
+ viewModel.onProFeedbackSelected() // show action
+ viewModel.onReportTypeSelected(REPORT_PROBLEM) // Show category
+ viewModel.onCategorySelected(DUCK_AI) // Show subcategory
+
+ viewModel.handleBackPress()
+
+ expectMostRecentItem().assertViewStateMoveBack(
+ expectedPreviousFragmentState = FeedbackAction,
+ expectedCurrentFragmentState = FeedbackCategory(R.string.feedbackActionReportIssue), // Back to category
+ FeedbackMetadata(
+ source = DDG_SETTINGS,
+ reportType = REPORT_PROBLEM,
+ ),
+ )
+ }
+ }
+
@Test
fun whenMoveBackFromSubCategoryActionViaSettingsAndVPNCategoryThenUpdateViewState() = runTest {
viewModel.viewState().test {
@@ -800,10 +880,11 @@ class SubscriptionFeedbackViewModelTest {
}
@Test
- fun whenMoveBackFromSubmitActionViaPproThenUpdateViewState() = runTest {
+ fun whenMoveBackFromSubmitActionViaSubscriptionsThenUpdateViewState() = runTest {
viewModel.viewState().test {
viewModel.allowUserToChooseReportType(source = SUBSCRIPTION_SETTINGS) // Show action
- viewModel.onReportTypeSelected(REPORT_PROBLEM) // Show subcategory
+ viewModel.onReportTypeSelected(REPORT_PROBLEM) // Show category
+ viewModel.onCategorySelected(category = SUBS_AND_PAYMENTS) // Show subcategories
viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.ONE_TIME_PASSWORD) // Show submit
viewModel.handleBackPress()
@@ -814,7 +895,7 @@ class SubscriptionFeedbackViewModelTest {
FeedbackMetadata(
source = SUBSCRIPTION_SETTINGS,
reportType = REPORT_PROBLEM,
- category = SUBS_AND_PAYMENTS, // Retain category
+ category = SUBS_AND_PAYMENTS,
),
)
}
@@ -865,6 +946,7 @@ class SubscriptionFeedbackViewModelTest {
fun whenSubsIssueSubmittedTheSendReportIssuePixel() = runTest {
viewModel.allowUserToChooseReportType(SUBSCRIPTION_SETTINGS)
viewModel.onReportTypeSelected(REPORT_PROBLEM)
+ viewModel.onCategorySelected(category = SUBS_AND_PAYMENTS)
viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.OTHER)
viewModel.onSubmitFeedback("Test")
@@ -965,6 +1047,7 @@ class SubscriptionFeedbackViewModelTest {
fun whenSubscriptionFeedbackWithEmailSucceedsThenSendBothToSupportInboxAndPixel() = runTest {
viewModel.allowUserToChooseReportType(SUBSCRIPTION_SETTINGS)
viewModel.onReportTypeSelected(REPORT_PROBLEM)
+ viewModel.onCategorySelected(category = SUBS_AND_PAYMENTS)
viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.OTHER)
viewModel.onSubmitFeedback("Test", "test@mail.com")
@@ -1005,6 +1088,7 @@ class SubscriptionFeedbackViewModelTest {
supportInbox.setSendFeedbackResult(false)
viewModel.allowUserToChooseReportType(SUBSCRIPTION_SETTINGS)
viewModel.onReportTypeSelected(REPORT_PROBLEM)
+ viewModel.onCategorySelected(SUBS_AND_PAYMENTS)
viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.OTHER)
viewModel.onSubmitFeedback("Test", "test@mail.com")
@@ -1029,6 +1113,7 @@ class SubscriptionFeedbackViewModelTest {
fun whenSubscriptionFeedbackWithBlankEmailThenSendPixelOnly() = runTest {
viewModel.allowUserToChooseReportType(SUBSCRIPTION_SETTINGS)
viewModel.onReportTypeSelected(REPORT_PROBLEM)
+ viewModel.onCategorySelected(SUBS_AND_PAYMENTS)
viewModel.onSubcategorySelected(SubscriptionFeedbackSubsSubCategory.OTHER)
viewModel.onSubmitFeedback("Test", " ")
diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/RealSubscriptionsJSHelperTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/RealSubscriptionsJSHelperTest.kt
index 7f8c62aa14b8..5b9b7754470c 100644
--- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/RealSubscriptionsJSHelperTest.kt
+++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/RealSubscriptionsJSHelperTest.kt
@@ -1,10 +1,14 @@
package com.duckduckgo.subscriptions.impl.messaging
+import android.annotation.SuppressLint
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.duckduckgo.common.test.CoroutineTestRule
+import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
import com.duckduckgo.js.messaging.api.JsCallbackData
import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE
import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED
+import com.duckduckgo.subscriptions.impl.AccessTokenResult
+import com.duckduckgo.subscriptions.impl.PrivacyProFeature
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY
@@ -15,6 +19,7 @@ import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
+import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -22,17 +27,24 @@ import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
@RunWith(AndroidJUnit4::class)
+@SuppressLint("DenyListedApi")
class RealSubscriptionsJSHelperTest {
@get:Rule
var coroutineRule = CoroutineTestRule()
private val mockSubscriptionsManager: SubscriptionsManager = mock()
+ private val privacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java)
- private val testee = RealSubscriptionsJSHelper(mockSubscriptionsManager)
+ private val testee = RealSubscriptionsJSHelper(mockSubscriptionsManager, privacyProFeature, coroutineRule.testDispatcherProvider)
private val featureName = "subscriptions"
+ @Before
+ fun setUp() {
+ // Set up any necessary initializations or mocks
+ }
+
@Test
fun whenMethodIsUnknownThenReturnNull() = runTest {
val method = "unknownMethod"
@@ -60,7 +72,15 @@ class RealSubscriptionsJSHelperTest {
val result = testee.processJsCallbackMessage(featureName, method, id, null)
val jsonPayload = JSONObject().apply {
- put("availableMessages", JSONArray().put("subscriptionDetails"))
+ put(
+ "availableMessages",
+ JSONArray().apply {
+ put("subscriptionDetails")
+ put("getAuthAccessToken")
+ put("getFeatureConfig")
+ put("authUpdate")
+ },
+ )
put("platform", "android")
}
@@ -164,4 +184,89 @@ class RealSubscriptionsJSHelperTest {
assertEquals(expected.featureName, result.featureName)
assertEquals(expected.params.toString(), result.params.toString())
}
+
+ @Test
+ fun whenGetAuthAccessTokenRequestWithSuccessfulTokenThenReturnJsCallbackDataWithToken() = runTest {
+ val method = "getAuthAccessToken"
+ val id = "123"
+ val expectedToken = "test-access-token"
+
+ whenever(mockSubscriptionsManager.getAccessToken()).thenReturn(AccessTokenResult.Success(expectedToken))
+
+ val result = testee.processJsCallbackMessage(featureName, method, id, null)
+
+ val jsonPayload = JSONObject().apply {
+ put("accessToken", expectedToken)
+ }
+
+ val expected = JsCallbackData(jsonPayload, featureName, method, id)
+
+ assertEquals(expected.id, result?.id)
+ assertEquals(expected.featureName, result?.featureName)
+ assertEquals(expected.method, result?.method)
+ assertEquals(expected.params.toString(), result?.params.toString())
+ }
+
+ @Test
+ fun whenGetAuthAccessTokenRequestWithFailedTokenThenReturnJsCallbackDataWithEmptyObject() = runTest {
+ val method = "getAuthAccessToken"
+ val id = "123"
+
+ whenever(mockSubscriptionsManager.getAccessToken()).thenReturn(AccessTokenResult.Failure("Token not found"))
+
+ val result = testee.processJsCallbackMessage(featureName, method, id, null)
+
+ val jsonPayload = JSONObject()
+
+ val expected = JsCallbackData(jsonPayload, featureName, method, id)
+
+ assertEquals(expected.id, result?.id)
+ assertEquals(expected.featureName, result?.featureName)
+ assertEquals(expected.method, result?.method)
+ assertEquals(expected.params.toString(), result?.params.toString())
+ }
+
+ @Test
+ fun whenGetFeatureConfigRequestThenReturnJsCallbackDataWithUsePaidDuckAiFlag() = runTest {
+ val method = "getFeatureConfig"
+ val id = "123"
+ val usePaidDuckAi = true
+
+ privacyProFeature.duckAiPlus().setRawStoredState(com.duckduckgo.feature.toggles.api.Toggle.State(usePaidDuckAi))
+
+ val result = testee.processJsCallbackMessage(featureName, method, id, null)
+
+ val jsonPayload = JSONObject().apply {
+ put("usePaidDuckAi", usePaidDuckAi)
+ }
+
+ val expected = JsCallbackData(jsonPayload, featureName, method, id)
+
+ assertEquals(expected.id, result?.id)
+ assertEquals(expected.featureName, result?.featureName)
+ assertEquals(expected.method, result?.method)
+ assertEquals(expected.params.toString(), result?.params.toString())
+ }
+
+ @Test
+ fun whenGetFeatureConfigRequestWithDisabledFlagThenReturnJsCallbackDataWithUsePaidDuckAiFalse() = runTest {
+ val method = "getFeatureConfig"
+ val id = "123"
+ val usePaidDuckAi = false
+
+ privacyProFeature.duckAiPlus().setRawStoredState(com.duckduckgo.feature.toggles.api.Toggle.State(usePaidDuckAi))
+
+ val result = testee.processJsCallbackMessage(featureName, method, id, null)
+
+ val jsonPayload = JSONObject().apply {
+ put("usePaidDuckAi", usePaidDuckAi)
+ }
+
+ val expected = JsCallbackData(jsonPayload, featureName, method, id)
+
+ assertEquals(expected.id, result?.id)
+ assertEquals(expected.featureName, result?.featureName)
+ assertEquals(expected.method, result?.method)
+ assertEquals(expected.params.toString(), result?.params.toString())
+ }
}
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 31c65e81f0c1..ee28c2c18c47 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
@@ -821,13 +821,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 = """
@@ -849,13 +850,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 = """
@@ -951,6 +982,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/messaging/SubscriptionsContentScopeJsMessageHandlerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsContentScopeJsMessageHandlerTest.kt
index 58025bdd932a..3355ac161373 100644
--- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsContentScopeJsMessageHandlerTest.kt
+++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionsContentScopeJsMessageHandlerTest.kt
@@ -40,9 +40,14 @@ class SubscriptionsContentScopeJsMessageHandlerTest {
@Test
fun `only contains valid methods`() = runTest {
val methods = handler.methods
- assertTrue(methods.size == 2)
- assertTrue(methods.first() == "handshake")
- assertTrue(methods.last() == "subscriptionDetails")
+ assertTrue(methods.size == 7)
+ assertTrue(methods.contains("handshake"))
+ assertTrue(methods.contains("subscriptionDetails"))
+ assertTrue(methods.contains("getAuthAccessToken"))
+ assertTrue(methods.contains("getFeatureConfig"))
+ assertTrue(methods.contains("backToSettings"))
+ assertTrue(methods.contains("openSubscriptionActivation"))
+ assertTrue(methods.contains("openSubscriptionPurchase"))
}
private val callback = object : JsMessageCallback() {
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 72efd5c63437..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
@@ -1,8 +1,15 @@
package com.duckduckgo.subscriptions.impl.settings.views
+import android.annotation.SuppressLint
import app.cash.turbine.test
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
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenBuyScreen
@@ -19,17 +26,26 @@ import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoMoreInteractions
import org.mockito.kotlin.whenever
+@SuppressLint("DenyListedApi")
class ProSettingViewModelTest {
@get:Rule
val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
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)
+ viewModel = ProSettingViewModel(
+ subscriptionRebrandingFeatureToggle,
+ subscriptionsManager,
+ pixelSender,
+ privacyProFeature,
+ coroutineTestRule.testDispatcherProvider,
+ )
}
@Test
@@ -96,4 +112,81 @@ class ProSettingViewModelTest {
cancelAndConsumeRemainingEvents()
}
}
+
+ @Test
+ fun whenDuckAiPlusEnabledIfSubscriptionPlanHasDuckAiThenDuckAiPlusAvailable() = runTest {
+ privacyProFeature.duckAiPlus().setRawStoredState(State(true))
+ 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)
+
+ viewModel.onCreate(mock())
+ viewModel.viewState.test {
+ assertTrue(awaitItem().duckAiPlusAvailable)
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun whenDuckAiPlusEnabledIfSubscriptionPlanDoesNotHaveDuckAiThenDuckAiPlusAvailable() = runTest {
+ privacyProFeature.duckAiPlus().setRawStoredState(State(true))
+ 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)
+
+ viewModel.onCreate(mock())
+ viewModel.viewState.test {
+ assertFalse(awaitItem().duckAiPlusAvailable)
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun whenDuckAiPlusDisabledIfSubscriptionPlanHasDuckAiThenDuckAiPlusAvailableFalse() = runTest {
+ privacyProFeature.duckAiPlus().setRawStoredState(State(false))
+ 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)
+
+ viewModel.onCreate(mock())
+ viewModel.viewState.test {
+ assertFalse(awaitItem().duckAiPlusAvailable)
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @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,
+ pricingPhases = emptyList(),
+ features = emptySet(),
+ )
}
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)
diff --git a/subscriptions/subscriptions-internal/build.gradle b/subscriptions/subscriptions-internal/build.gradle
index 3468fa64066a..04aae91fce78 100644
--- a/subscriptions/subscriptions-internal/build.gradle
+++ b/subscriptions/subscriptions-internal/build.gradle
@@ -32,6 +32,7 @@ android {
dependencies {
anvil project(':anvil-compiler')
implementation project(':anvil-annotations')
+ implementation project(':subscriptions-api')
implementation project(':subscriptions-impl')
implementation project(':di')
implementation project(':common-utils')
diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/RecoverSubscriptionView.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/RecoverSubscriptionView.kt
index 82c8241a876a..b1bea36711b8 100644
--- a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/RecoverSubscriptionView.kt
+++ b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/RecoverSubscriptionView.kt
@@ -25,7 +25,7 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
-import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionActivity.Companion.RestoreSubscriptionScreenWithParams
+import com.duckduckgo.subscriptions.api.SubscriptionScreens.RestoreSubscriptionScreenWithParams
import com.duckduckgo.subscriptions.internal.SubsSettingPlugin
import com.duckduckgo.subscriptions.internal.databinding.SubsSimpleViewBinding
import com.squareup.anvil.annotations.ContributesMultibinding