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