Skip to content

Commit 2c15ec1

Browse files
committed
Add duck.ai chat - subscriptions integration (#6350)
Task/Issue URL: https://app.asana.com/1/137249556945/task/1210461583262416?focus=true ### Description Adds integration between duck.ai and subscriptions. Includes: - messaging for Feature Flags in FE - share token - navigational events ### Steps to test this PR _Feature 1_ - [x] Apply patch from https://app.asana.com/1/137249556945/project/1149059203486286/task/1210461583262416?focus=true - [x] Fresh install - [x] Install branch - [x] Open Duck.ai, if onboarding shows, skip it - [x] After onboarding, Duck.ai should be displayed as Free (Background logo just says "Duck.ai Free", models selector only display free models and no upsells) - [ ] ~Go to models selector, you should see the advance models~ - [ ] ~If you select one, the upsell appears "Unlock with a DuckDuckGo...."~ - [ ] ~Clicking on the upsell takes you to subscription flow~ - [ ] ~Ensure it does, and go back to chat~ - [ ] ~Click on Duck.ai settings (top right)~ - [ ] ~The options "Subscribe to DuckDuckGo" and "I have a subscription" appear~ - [ ] ~Click on "I have a Subscription"~ - [ ] ~Ensure it navigate to activation flow~ - [ ] ~Go back to Duck.ai~ - [x] go to settings, and purchase a subscription (or go there using any upsell link) (after subscription restart the app, this is to avoid Duck.ai chat being restored, something we need to fix in https://app.asana.com/1/137249556945/project/1149059203486286/task/1210671370392466?focus=true) - [x] Open Duck.ai again (from any entry point) - [x] An Duck.ai paid onboarding appears (depending if this is your first visit as subscriber) - [x] Duck.ai is in subscriber mode (background logo has changed "Subscriber" and model selector includes advanced models) - [x] Inside Duck.ai settings now it shows "manage", and if you click there you can navigate to subscription settings ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)|
1 parent 7b798b9 commit 2c15ec1

File tree

16 files changed

+572
-29
lines changed

16 files changed

+572
-29
lines changed

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import com.duckduckgo.js.messaging.api.JsMessageCallback
7272
import com.duckduckgo.js.messaging.api.JsMessaging
7373
import com.duckduckgo.navigation.api.GlobalActivityStarter
7474
import com.duckduckgo.navigation.api.getActivityParams
75+
import com.duckduckgo.subscriptions.api.SUBSCRIPTIONS_FEATURE_NAME
7576
import com.google.android.material.snackbar.BaseTransientBottomBar
7677
import com.google.android.material.snackbar.Snackbar
7778
import java.io.File
@@ -101,6 +102,9 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
101102
@Inject
102103
lateinit var duckChatJSHelper: DuckChatJSHelper
103104

105+
@Inject
106+
lateinit var subscriptionsHandler: SubscriptionsHandler
107+
104108
@Inject
105109
@AppCoroutineScope
106110
lateinit var appCoroutineScope: CoroutineScope
@@ -240,6 +244,19 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
240244
}
241245
}
242246
}
247+
248+
SUBSCRIPTIONS_FEATURE_NAME -> {
249+
subscriptionsHandler.handleSubscriptionsFeature(
250+
featureName,
251+
method,
252+
id,
253+
data,
254+
this@DuckChatWebViewActivity,
255+
appCoroutineScope,
256+
contentScopeScripts,
257+
)
258+
}
259+
243260
else -> {}
244261
}
245262
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromEx
7272
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.NoMediaCaptured
7373
import com.duckduckgo.js.messaging.api.JsMessageCallback
7474
import com.duckduckgo.js.messaging.api.JsMessaging
75+
import com.duckduckgo.subscriptions.api.SUBSCRIPTIONS_FEATURE_NAME
7576
import com.google.android.material.snackbar.BaseTransientBottomBar
7677
import com.google.android.material.snackbar.Snackbar
7778
import java.io.File
@@ -96,6 +97,9 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c
9697
@Inject
9798
lateinit var duckChatJSHelper: DuckChatJSHelper
9899

100+
@Inject
101+
lateinit var subscriptionsHandler: SubscriptionsHandler
102+
99103
@Inject
100104
@AppCoroutineScope
101105
lateinit var appCoroutineScope: CoroutineScope
@@ -236,6 +240,18 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c
236240
}
237241
}
238242

243+
SUBSCRIPTIONS_FEATURE_NAME -> {
244+
subscriptionsHandler.handleSubscriptionsFeature(
245+
featureName,
246+
method,
247+
id,
248+
data,
249+
requireActivity(),
250+
appCoroutineScope,
251+
contentScopeScripts,
252+
)
253+
}
254+
239255
else -> {}
240256
}
241257
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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.ui
18+
19+
import android.content.Context
20+
import com.duckduckgo.common.utils.DispatcherProvider
21+
import com.duckduckgo.js.messaging.api.JsMessaging
22+
import com.duckduckgo.navigation.api.GlobalActivityStarter
23+
import com.duckduckgo.subscriptions.api.SubscriptionScreens.RestoreSubscriptionScreenWithParams
24+
import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionScreenNoParams
25+
import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionsSettingsScreenWithEmptyParams
26+
import com.duckduckgo.subscriptions.api.SubscriptionsJSHelper
27+
import javax.inject.Inject
28+
import kotlinx.coroutines.CoroutineScope
29+
import kotlinx.coroutines.launch
30+
import kotlinx.coroutines.withContext
31+
import org.json.JSONObject
32+
33+
class SubscriptionsHandler @Inject constructor(
34+
private val subscriptionsJSHelper: SubscriptionsJSHelper,
35+
private val globalActivityStarter: GlobalActivityStarter,
36+
private val dispatcherProvider: DispatcherProvider,
37+
) {
38+
39+
fun handleSubscriptionsFeature(
40+
featureName: String,
41+
method: String,
42+
id: String?,
43+
data: JSONObject?,
44+
context: Context,
45+
appCoroutineScope: CoroutineScope,
46+
contentScopeScripts: JsMessaging,
47+
) {
48+
appCoroutineScope.launch(dispatcherProvider.io()) {
49+
val response = subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data)
50+
withContext(dispatcherProvider.main()) {
51+
response?.let {
52+
contentScopeScripts.onResponse(response)
53+
}
54+
}
55+
56+
when (method) {
57+
METHOD_BACK_TO_SETTINGS -> {
58+
withContext(dispatcherProvider.main()) {
59+
globalActivityStarter.start(context, SubscriptionsSettingsScreenWithEmptyParams)
60+
}
61+
}
62+
63+
METHOD_OPEN_SUBSCRIPTION_ACTIVATION -> {
64+
withContext(dispatcherProvider.main()) {
65+
globalActivityStarter.start(context, RestoreSubscriptionScreenWithParams(isOriginWeb = true))
66+
}
67+
}
68+
69+
METHOD_OPEN_SUBSCRIPTION_PURCHASE -> {
70+
withContext(dispatcherProvider.main()) {
71+
globalActivityStarter.start(context, SubscriptionScreenNoParams)
72+
}
73+
}
74+
75+
else -> {}
76+
}
77+
}
78+
}
79+
80+
companion object {
81+
private const val METHOD_BACK_TO_SETTINGS = "backToSettings"
82+
private const val METHOD_OPEN_SUBSCRIPTION_ACTIVATION = "openSubscriptionActivation"
83+
private const val METHOD_OPEN_SUBSCRIPTION_PURCHASE = "openSubscriptionPurchase"
84+
}
85+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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.ui
18+
19+
import android.content.Context
20+
import com.duckduckgo.common.test.CoroutineTestRule
21+
import com.duckduckgo.js.messaging.api.JsCallbackData
22+
import com.duckduckgo.js.messaging.api.JsMessaging
23+
import com.duckduckgo.navigation.api.GlobalActivityStarter
24+
import com.duckduckgo.subscriptions.api.SubscriptionScreens.RestoreSubscriptionScreenWithParams
25+
import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionScreenNoParams
26+
import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionsSettingsScreenWithEmptyParams
27+
import com.duckduckgo.subscriptions.api.SubscriptionsJSHelper
28+
import kotlinx.coroutines.test.runTest
29+
import org.json.JSONObject
30+
import org.junit.Before
31+
import org.junit.Rule
32+
import org.junit.Test
33+
import org.mockito.Mock
34+
import org.mockito.MockitoAnnotations
35+
import org.mockito.kotlin.any
36+
import org.mockito.kotlin.never
37+
import org.mockito.kotlin.verify
38+
import org.mockito.kotlin.whenever
39+
40+
class SubscriptionsHandlerTest {
41+
42+
@get:Rule
43+
var coroutineRule = CoroutineTestRule()
44+
45+
private val dispatcherProvider = coroutineRule.testDispatcherProvider
46+
47+
@Mock
48+
private lateinit var subscriptionsJSHelper: SubscriptionsJSHelper
49+
50+
@Mock
51+
private lateinit var globalActivityStarter: GlobalActivityStarter
52+
53+
@Mock
54+
private lateinit var context: Context
55+
56+
@Mock
57+
private lateinit var contentScopeScripts: JsMessaging
58+
59+
private lateinit var subscriptionsHandler: SubscriptionsHandler
60+
61+
@Before
62+
fun setUp() {
63+
MockitoAnnotations.openMocks(this)
64+
65+
subscriptionsHandler = SubscriptionsHandler(
66+
subscriptionsJSHelper,
67+
globalActivityStarter,
68+
dispatcherProvider,
69+
)
70+
}
71+
72+
@Test
73+
fun `handleSubscriptionsFeature processes js callback and responds when response is not null`() = runTest {
74+
val featureName = "subscriptions"
75+
val method = "someMethod"
76+
val id = "testId"
77+
val data = JSONObject()
78+
val response = JsCallbackData(JSONObject(), featureName, method, id)
79+
whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
80+
.thenReturn(response)
81+
82+
subscriptionsHandler.handleSubscriptionsFeature(
83+
featureName,
84+
method,
85+
id,
86+
data,
87+
context,
88+
coroutineRule.testScope,
89+
contentScopeScripts,
90+
)
91+
92+
verify(subscriptionsJSHelper).processJsCallbackMessage(featureName, method, id, data)
93+
verify(contentScopeScripts).onResponse(response)
94+
}
95+
96+
@Test
97+
fun `handleSubscriptionsFeature processes js callback but does not respond when response is null`() = runTest {
98+
val featureName = "subscriptions"
99+
val method = "someMethod"
100+
val id = "testId"
101+
val data = JSONObject()
102+
whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
103+
.thenReturn(null)
104+
105+
subscriptionsHandler.handleSubscriptionsFeature(
106+
featureName,
107+
method,
108+
id,
109+
data,
110+
context,
111+
coroutineRule.testScope,
112+
contentScopeScripts,
113+
)
114+
115+
verify(subscriptionsJSHelper).processJsCallbackMessage(featureName, method, id, data)
116+
verify(contentScopeScripts, never()).onResponse(any())
117+
}
118+
119+
@Test
120+
fun `handleSubscriptionsFeature launches settings screen when method is backToSettings`() = runTest {
121+
val featureName = "subscriptions"
122+
val method = "backToSettings"
123+
val id = "testId"
124+
val data = JSONObject()
125+
val response = JsCallbackData(JSONObject(), featureName, method, id)
126+
whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
127+
.thenReturn(response)
128+
129+
subscriptionsHandler.handleSubscriptionsFeature(
130+
featureName,
131+
method,
132+
id,
133+
data,
134+
context,
135+
coroutineRule.testScope,
136+
contentScopeScripts,
137+
)
138+
139+
verify(globalActivityStarter).start(context, SubscriptionsSettingsScreenWithEmptyParams)
140+
}
141+
142+
@Test
143+
fun `handleSubscriptionsFeature launches subscription activation screen when method is openSubscriptionActivation`() = runTest {
144+
val featureName = "subscriptions"
145+
val method = "openSubscriptionActivation"
146+
val id = "testId"
147+
val data = JSONObject()
148+
val response = JsCallbackData(JSONObject(), featureName, method, id)
149+
whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
150+
.thenReturn(response)
151+
152+
subscriptionsHandler.handleSubscriptionsFeature(
153+
featureName,
154+
method,
155+
id,
156+
data,
157+
context,
158+
coroutineRule.testScope,
159+
contentScopeScripts,
160+
)
161+
162+
verify(globalActivityStarter).start(context, RestoreSubscriptionScreenWithParams(isOriginWeb = true))
163+
}
164+
165+
@Test
166+
fun `handleSubscriptionsFeature launches subscription purchase screen when method is openSubscriptionPurchase`() = runTest {
167+
val featureName = "subscriptions"
168+
val method = "openSubscriptionPurchase"
169+
val id = "testId"
170+
val data = JSONObject()
171+
val response = JsCallbackData(JSONObject(), featureName, method, id)
172+
whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
173+
.thenReturn(response)
174+
175+
subscriptionsHandler.handleSubscriptionsFeature(
176+
featureName,
177+
method,
178+
id,
179+
data,
180+
context,
181+
coroutineRule.testScope,
182+
contentScopeScripts,
183+
)
184+
185+
verify(globalActivityStarter).start(context, SubscriptionScreenNoParams)
186+
}
187+
188+
@Test
189+
fun `handleSubscriptionsFeature handles null data parameter`() = runTest {
190+
val featureName = "subscriptions"
191+
val method = "backToSettings"
192+
val id = "testId"
193+
val data: JSONObject? = null
194+
val response = JsCallbackData(JSONObject(), featureName, method, id)
195+
whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
196+
.thenReturn(response)
197+
198+
subscriptionsHandler.handleSubscriptionsFeature(
199+
featureName,
200+
method,
201+
id,
202+
data,
203+
context,
204+
coroutineRule.testScope,
205+
contentScopeScripts,
206+
)
207+
208+
verify(subscriptionsJSHelper).processJsCallbackMessage(featureName, method, id, data)
209+
verify(globalActivityStarter).start(context, SubscriptionsSettingsScreenWithEmptyParams)
210+
}
211+
212+
@Test
213+
fun `handleSubscriptionsFeature handles null id parameter`() = runTest {
214+
val featureName = "subscriptions"
215+
val method = "openSubscriptionPurchase"
216+
val id: String? = null
217+
val data = JSONObject()
218+
val response = JsCallbackData(JSONObject(), featureName, method, "")
219+
whenever(subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data))
220+
.thenReturn(response)
221+
222+
subscriptionsHandler.handleSubscriptionsFeature(
223+
featureName,
224+
method,
225+
id,
226+
data,
227+
context,
228+
coroutineRule.testScope,
229+
contentScopeScripts,
230+
)
231+
232+
verify(subscriptionsJSHelper).processJsCallbackMessage(featureName, method, id, data)
233+
verify(globalActivityStarter).start(context, SubscriptionScreenNoParams)
234+
}
235+
}

0 commit comments

Comments
 (0)