Skip to content

Commit 64d6881

Browse files
committed
Shows Duck.ai pro settings for subscribers
1 parent c307f39 commit 64d6881

File tree

14 files changed

+639
-9
lines changed

14 files changed

+639
-9
lines changed

duckchat/duckchat-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dependencies {
3333
implementation project(':js-messaging-api')
3434
implementation project(':downloads-api')
3535
implementation project(':content-scope-scripts-api')
36+
implementation project(':subscriptions-api')
3637

3738
anvil project(path: ':anvil-compiler')
3839
implementation project(path: ':anvil-annotations')

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,7 @@ interface DuckChatFeature {
3535
*/
3636
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
3737
fun self(): Toggle
38+
39+
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
40+
fun duckAiPlus(): Toggle
3841
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.subscription
18+
19+
import android.content.Context
20+
import android.view.View
21+
import com.duckduckgo.anvil.annotations.PriorityKey
22+
import com.duckduckgo.di.scopes.ActivityScope
23+
import com.duckduckgo.settings.api.ProSettingsPlugin
24+
import com.squareup.anvil.annotations.ContributesMultibinding
25+
import javax.inject.Inject
26+
27+
@ContributesMultibinding(scope = ActivityScope::class)
28+
@PriorityKey(350)
29+
class DuckAiPlusSettings @Inject constructor() : ProSettingsPlugin {
30+
override fun getView(context: Context): View {
31+
return DuckAiPlusSettingsView(context)
32+
}
33+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.subscription
18+
19+
import android.content.Context
20+
import android.util.AttributeSet
21+
import android.widget.FrameLayout
22+
import androidx.core.view.isGone
23+
import androidx.core.view.isVisible
24+
import androidx.lifecycle.ViewModelProvider
25+
import androidx.lifecycle.findViewTreeLifecycleOwner
26+
import androidx.lifecycle.findViewTreeViewModelStoreOwner
27+
import androidx.lifecycle.lifecycleScope
28+
import com.duckduckgo.anvil.annotations.InjectWith
29+
import com.duckduckgo.common.ui.viewbinding.viewBinding
30+
import com.duckduckgo.common.utils.ConflatedJob
31+
import com.duckduckgo.common.utils.DispatcherProvider
32+
import com.duckduckgo.common.utils.ViewViewModelFactory
33+
import com.duckduckgo.di.scopes.ViewScope
34+
import com.duckduckgo.duckchat.impl.databinding.ViewDuckAiSettingsBinding
35+
import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.Command
36+
import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.Command.OpenDuckAiPlusSettings
37+
import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState
38+
import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState.SettingState
39+
import com.duckduckgo.navigation.api.GlobalActivityStarter
40+
import dagger.android.support.AndroidSupportInjection
41+
import javax.inject.Inject
42+
import kotlinx.coroutines.flow.launchIn
43+
import kotlinx.coroutines.flow.onEach
44+
45+
@InjectWith(ViewScope::class)
46+
class DuckAiPlusSettingsView @JvmOverloads constructor(
47+
context: Context,
48+
attrs: AttributeSet? = null,
49+
defStyle: Int = 0,
50+
) : FrameLayout(context, attrs, defStyle) {
51+
52+
@Inject
53+
lateinit var viewModelFactory: ViewViewModelFactory
54+
55+
@Inject
56+
lateinit var globalActivityStarter: GlobalActivityStarter
57+
58+
@Inject
59+
lateinit var dispatchers: DispatcherProvider
60+
61+
private val binding: ViewDuckAiSettingsBinding by viewBinding()
62+
63+
private val viewModel: DuckAiPlusSettingsViewModel by lazy {
64+
ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[DuckAiPlusSettingsViewModel::class.java]
65+
}
66+
67+
private var job: ConflatedJob = ConflatedJob()
68+
private var conflatedStateJob: ConflatedJob = ConflatedJob()
69+
70+
override fun onAttachedToWindow() {
71+
AndroidSupportInjection.inject(this)
72+
super.onAttachedToWindow()
73+
74+
findViewTreeLifecycleOwner()?.lifecycle?.addObserver(viewModel)
75+
val coroutineScope = findViewTreeLifecycleOwner()?.lifecycleScope
76+
77+
job += viewModel.commands()
78+
.onEach { processCommands(it) }
79+
.launchIn(coroutineScope!!)
80+
81+
conflatedStateJob += viewModel.viewState
82+
.onEach { renderView(it) }
83+
.launchIn(coroutineScope!!)
84+
}
85+
86+
override fun onDetachedFromWindow() {
87+
super.onDetachedFromWindow()
88+
findViewTreeLifecycleOwner()?.lifecycle?.removeObserver(viewModel)
89+
job.cancel()
90+
conflatedStateJob.cancel()
91+
}
92+
93+
private fun renderView(viewState: ViewState) {
94+
with(binding.duckAiSettings) {
95+
when (viewState.settingState) {
96+
is SettingState.Enabled -> {
97+
isVisible = true
98+
setStatus(isOn = true)
99+
setLeadingIconResource(com.duckduckgo.mobile.android.R.drawable.ic_ai_chat_color_24)
100+
isClickable = true
101+
setClickListener { viewModel.onDuckAiClicked() }
102+
}
103+
SettingState.Disabled -> {
104+
isVisible = true
105+
isClickable = false
106+
setStatus(isOn = false)
107+
setClickListener(null)
108+
// TODO: we need a disabled state icon
109+
setLeadingIconResource(com.duckduckgo.mobile.android.R.drawable.ic_ai_chat_color_24)
110+
}
111+
SettingState.Hidden -> isGone = true
112+
}
113+
}
114+
}
115+
116+
private fun processCommands(command: Command) {
117+
when (command) {
118+
is OpenDuckAiPlusSettings -> {
119+
// TODO: navigate to Duck Ai Plus settings
120+
}
121+
}
122+
}
123+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.subscription
18+
19+
import android.annotation.SuppressLint
20+
import androidx.lifecycle.DefaultLifecycleObserver
21+
import androidx.lifecycle.LifecycleOwner
22+
import androidx.lifecycle.ViewModel
23+
import androidx.lifecycle.viewModelScope
24+
import com.duckduckgo.anvil.annotations.ContributesViewModel
25+
import com.duckduckgo.di.scopes.ViewScope
26+
import com.duckduckgo.duckchat.impl.feature.DuckChatFeature
27+
import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState.SettingState
28+
import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState.SettingState.Disabled
29+
import com.duckduckgo.duckchat.impl.subscription.DuckAiPlusSettingsViewModel.ViewState.SettingState.Hidden
30+
import com.duckduckgo.subscriptions.api.Product.DuckAiPlus
31+
import com.duckduckgo.subscriptions.api.SubscriptionStatus
32+
import com.duckduckgo.subscriptions.api.Subscriptions
33+
import javax.inject.Inject
34+
import kotlinx.coroutines.channels.BufferOverflow
35+
import kotlinx.coroutines.channels.Channel
36+
import kotlinx.coroutines.flow.Flow
37+
import kotlinx.coroutines.flow.MutableStateFlow
38+
import kotlinx.coroutines.flow.asStateFlow
39+
import kotlinx.coroutines.flow.launchIn
40+
import kotlinx.coroutines.flow.map
41+
import kotlinx.coroutines.flow.onEach
42+
import kotlinx.coroutines.flow.onStart
43+
import kotlinx.coroutines.flow.receiveAsFlow
44+
import kotlinx.coroutines.flow.update
45+
import kotlinx.coroutines.launch
46+
import logcat.logcat
47+
48+
@SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle
49+
@ContributesViewModel(ViewScope::class)
50+
class DuckAiPlusSettingsViewModel @Inject constructor(
51+
private val subscriptions: Subscriptions,
52+
private val duckChatFeature: DuckChatFeature,
53+
) : ViewModel(), DefaultLifecycleObserver {
54+
55+
sealed class Command {
56+
data object OpenDuckAiPlusSettings : Command()
57+
}
58+
59+
private val command = Channel<Command>(1, BufferOverflow.DROP_OLDEST)
60+
internal fun commands(): Flow<Command> = command.receiveAsFlow()
61+
data class ViewState(val settingState: SettingState = Hidden) {
62+
63+
sealed class SettingState {
64+
65+
data object Hidden : SettingState()
66+
data object Enabled : SettingState()
67+
data object Disabled : SettingState()
68+
}
69+
}
70+
71+
private val _viewState = MutableStateFlow(ViewState())
72+
val viewState = _viewState.asStateFlow().onStart {
73+
if (duckChatFeature.duckAiPlus().isEnabled().not()) {
74+
_viewState.value = ViewState(settingState = Hidden)
75+
}
76+
}
77+
78+
fun onDuckAiClicked() {
79+
sendCommand(Command.OpenDuckAiPlusSettings)
80+
}
81+
82+
override fun onCreate(owner: LifecycleOwner) {
83+
super.onCreate(owner)
84+
85+
viewModelScope.launch {
86+
if (duckChatFeature.duckAiPlus().isEnabled().not()) return@launch
87+
88+
subscriptions.getEntitlementStatus().map { entitlements ->
89+
logcat { "CRIS: getEntitlementStatus $entitlements" }
90+
entitlements.any { product ->
91+
product == DuckAiPlus
92+
}
93+
}.onEach { hasValidEntitlement ->
94+
val subscriptionStatus = subscriptions.getSubscriptionStatus()
95+
val state = getDuckAiProState(hasValidEntitlement, subscriptionStatus)
96+
_viewState.update { it.copy(settingState = state) }
97+
}.launchIn(viewModelScope)
98+
}
99+
}
100+
101+
private suspend fun getDuckAiProState(
102+
hasValidEntitlement: Boolean,
103+
subscriptionStatus: SubscriptionStatus,
104+
): SettingState {
105+
return when (subscriptionStatus) {
106+
SubscriptionStatus.UNKNOWN -> Hidden
107+
SubscriptionStatus.INACTIVE,
108+
SubscriptionStatus.EXPIRED,
109+
SubscriptionStatus.WAITING,
110+
-> {
111+
if (isDuckAiProAvailable()) {
112+
Disabled
113+
} else {
114+
Hidden
115+
}
116+
}
117+
118+
SubscriptionStatus.AUTO_RENEWABLE,
119+
SubscriptionStatus.NOT_AUTO_RENEWABLE,
120+
SubscriptionStatus.GRACE_PERIOD,
121+
-> {
122+
if (hasValidEntitlement) {
123+
SettingState.Enabled
124+
} else {
125+
Hidden
126+
}
127+
}
128+
}
129+
}
130+
131+
private suspend fun isDuckAiProAvailable(): Boolean {
132+
return subscriptions.getAvailableProducts().also {
133+
logcat { "CRIS: available products: $it" }
134+
}.any { availableProduct ->
135+
availableProduct == DuckAiPlus
136+
}
137+
}
138+
139+
private fun sendCommand(newCommand: Command) {
140+
viewModelScope.launch {
141+
command.send(newCommand)
142+
}
143+
}
144+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
~ Copyright (c) 2025 DuckDuckGo
3+
~
4+
~ Licensed under the Apache License, Version 2.0 (the "License");
5+
~ you may not use this file except in compliance with the License.
6+
~ You may obtain a copy of the License at
7+
~
8+
~ http://www.apache.org/licenses/LICENSE-2.0
9+
~
10+
~ Unless required by applicable law or agreed to in writing, software
11+
~ distributed under the License is distributed on an "AS IS" BASIS,
12+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
~ See the License for the specific language governing permissions and
14+
~ limitations under the License.
15+
-->
16+
17+
<com.duckduckgo.common.ui.view.listitem.SettingsListItem xmlns:android="http://schemas.android.com/apk/res/android"
18+
xmlns:app="http://schemas.android.com/apk/res-auto"
19+
android:id="@+id/duckAiSettings"
20+
android:layout_width="match_parent"
21+
android:layout_height="wrap_content"
22+
app:indicatorStatus="on"
23+
app:leadingIcon="@drawable/ic_ai_chat_color_24"
24+
app:primaryText="@string/duck_ai_paid_settings_title" />

duckchat/duckchat-impl/src/main/res/values/donottranslate.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,6 @@
2323
<string name="duck_chat_stop_generating">Stop generating</string>
2424
<string name="duck_chat_search_tab">Search</string>
2525
<string name="duck_chat_ai_tab">Duck.ai</string>
26+
<!-- Duck.ai Pro settings -->
27+
<string name="duck_ai_paid_settings_title">Duck.ai</string>
2628
</resources>

0 commit comments

Comments
 (0)