Skip to content

Commit 3146732

Browse files
authored
Add Subscriptions SERP integration (#6061)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1201807753394693/task/1210023557524902?focus=true ### Description - Uses the C-S-S navigatorInterface + messageBridge to request information about the subscription status. - The functionality is disabled when aiChat is disabled in the config. ### Steps to test this PR _Pre steps_ - [x] Change remote config URL to https://jsonblob.com/api/1371926095833784320 - [x] Add logs to see the JS messages requests and responses within `fun processJsCallbackMessage()` - In line _3577_ log JS request `Timber.d("JsRequestMessage, featureName: $featureName, method: $method, id: $id")` - In line _3631_ log JS response `Timber.e("JS response: $it")` - [x] Use a VPN to set your location to US - [x] Apply patch to be privacy pro eligible _Subscriptions message non US_ - [x] Install from branch - [x] Perform a search - [x] Check you don't get any messages for featureName _subscriptions_ as you are not in US _Handshake request_ - [x] Set a VPN to US - [x] Install from branch - [x] Perform a search - [x] Verify you see in the logs the message request: `featureName: subscriptions, method: handshake` - [x] Verify you see in the logs the message response: `JsCallbackData(params={"availableMessages":["subscriptionDetails"],"platform":"android"}, featureName=subscriptions, method=handshake` ... _subscriptionDetails request with no Subscription_ - [x] Verify you see in the logs the message request: `featureName: subscriptions, method: subscriptionDetails` - [x] Verify you see in the logs the message response: `{"isSubscribed":false}` ... _subscriptionDetails request with active Subscription_ - [x] from previous test, go to Settings > Privacy Pro - [x] Purchase a subscription - [x] Perform a search - [x] Verify you see in the logs the message request: `featureName: subscriptions, method: subscriptionDetails` - [x] Verify you see in the logs the message response: `{"isSubscribed":true,"billingPeriod":"Monthly or Yearly","startedAt":...,"expiresOrRenewsAt":...,"paymentPlatform":"google","status":"Auto-Renewable"}` ... _subscriptionDetails request with expired Subscription_ - [x] from previous test, go to Settings > Privacy Pro Settings - [x] Cancel the subscription - [x] Wait until subscription expires - [x] Perform a search - [x] Verify you see in the logs the message request: `featureName: subscriptions, method: subscriptionDetails` - [x] Verify you see in the logs the message response: `{"isSubscribed":false,"billingPeriod":"Monthly or Yearly","startedAt":...,"expiresOrRenewsAt":...,"paymentPlatform":"google","status":"Expired"}` ### No UI changes
1 parent b0a199f commit 3146732

File tree

6 files changed

+399
-1
lines changed

6 files changed

+399
-1
lines changed

app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,8 @@ import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermis
277277
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse
278278
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions
279279
import com.duckduckgo.subscriptions.api.Subscriptions
280+
import com.duckduckgo.subscriptions.impl.messaging.RealSubscriptionsJSHelper.Companion.SUBSCRIPTIONS_FEATURE_NAME
281+
import com.duckduckgo.subscriptions.impl.messaging.SubscriptionsJSHelper
280282
import com.duckduckgo.sync.api.favicons.FaviconsFetchingPrompt
281283
import com.duckduckgo.voice.api.VoiceSearchAvailability
282284
import com.duckduckgo.voice.api.VoiceSearchAvailabilityPixelLogger
@@ -550,6 +552,7 @@ class BrowserTabViewModelTest {
550552
private val mockSiteErrorHandlerKillSwitchToggle: Toggle = mock { on { it.isEnabled() } doReturn true }
551553
private val mockSiteErrorHandler: StringSiteErrorHandler = mock()
552554
private val mockSiteHttpErrorHandler: HttpCodeSiteErrorHandler = mock()
555+
private val mockSubscriptionsJSHelper: SubscriptionsJSHelper = mock()
553556

554557
private val selectedTab = TabEntity("TAB_ID", "https://example.com", position = 0, sourceTabId = "TAB_ID_SOURCE")
555558

@@ -742,6 +745,7 @@ class BrowserTabViewModelTest {
742745
siteErrorHandler = mockSiteErrorHandler,
743746
siteHttpErrorHandler = mockSiteHttpErrorHandler,
744747
senseOfProtectionExperiment = mockSenseOfProtectionExperiment,
748+
subscriptionsJSHelper = mockSubscriptionsJSHelper,
745749
)
746750

747751
testee.loadData("abc", null, false, false)
@@ -6294,6 +6298,33 @@ class BrowserTabViewModelTest {
62946298
verify(mockDuckChat, never()).openDuckChat()
62956299
}
62966300

6301+
@Test
6302+
fun whenProcessJsCallbackMessageForSubscriptionsThenSendCommand() = runTest {
6303+
val jsCallbackData = JsCallbackData(JSONObject(), "", "", "")
6304+
whenever(mockSubscriptionsJSHelper.processJsCallbackMessage(anyString(), anyString(), anyOrNull(), anyOrNull())).thenReturn(jsCallbackData)
6305+
testee.processJsCallbackMessage(
6306+
featureName = SUBSCRIPTIONS_FEATURE_NAME,
6307+
method = "method",
6308+
id = "id",
6309+
data = null,
6310+
) { "someUrl" }
6311+
verify(mockSubscriptionsJSHelper).processJsCallbackMessage(SUBSCRIPTIONS_FEATURE_NAME, "method", "id", null)
6312+
assertCommandIssued<Command.SendResponseToJs>()
6313+
}
6314+
6315+
@Test
6316+
fun whenProcessJsCallbackMessageForSubscriptionsAndResponseIsNullThenDoNotSendCommand() = runTest {
6317+
whenever(mockDuckChatJSHelper.processJsCallbackMessage(anyString(), anyString(), anyOrNull(), anyOrNull())).thenReturn(null)
6318+
testee.processJsCallbackMessage(
6319+
featureName = SUBSCRIPTIONS_FEATURE_NAME,
6320+
method = "method",
6321+
id = "id",
6322+
data = null,
6323+
) { "someUrl" }
6324+
verify(mockSubscriptionsJSHelper).processJsCallbackMessage(SUBSCRIPTIONS_FEATURE_NAME, "method", "id", null)
6325+
assertCommandNotIssued<Command.SendResponseToJs>()
6326+
}
6327+
62976328
private fun aCredential(): LoginCredentials {
62986329
return LoginCredentials(domain = null, username = null, password = null)
62996330
}

app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@ import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermis
342342
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse
343343
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions
344344
import com.duckduckgo.subscriptions.api.Subscriptions
345+
import com.duckduckgo.subscriptions.impl.messaging.RealSubscriptionsJSHelper.Companion.SUBSCRIPTIONS_FEATURE_NAME
346+
import com.duckduckgo.subscriptions.impl.messaging.SubscriptionsJSHelper
345347
import com.duckduckgo.sync.api.favicons.FaviconsFetchingPrompt
346348
import dagger.Lazy
347349
import io.reactivex.schedulers.Schedulers
@@ -479,6 +481,7 @@ class BrowserTabViewModel @Inject constructor(
479481
private val siteErrorHandler: StringSiteErrorHandler,
480482
private val siteHttpErrorHandler: HttpCodeSiteErrorHandler,
481483
private val senseOfProtectionExperiment: SenseOfProtectionExperiment,
484+
private val subscriptionsJSHelper: SubscriptionsJSHelper,
482485
) : WebViewClientListener,
483486
EditSavedSiteListener,
484487
DeleteBookmarkListener,
@@ -3619,6 +3622,17 @@ class BrowserTabViewModel @Inject constructor(
36193622
}
36203623
}
36213624

3625+
SUBSCRIPTIONS_FEATURE_NAME -> {
3626+
viewModelScope.launch(dispatchers.io()) {
3627+
val response = subscriptionsJSHelper.processJsCallbackMessage(featureName, method, id, data)
3628+
withContext(dispatchers.main()) {
3629+
response?.let {
3630+
command.value = SendResponseToJs(it)
3631+
}
3632+
}
3633+
}
3634+
}
3635+
36223636
else -> {}
36233637
}
36243638
}

content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,13 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
6262
override val secret: String = coreContentScopeScripts.secret
6363
override val allowedDomains: List<String> = emptyList()
6464

65-
private val handlers: List<JsMessageHandler> = listOf(ContentScopeHandler(), breakageHandler, DuckPlayerHandler(), DuckChatHandler())
65+
private val handlers: List<JsMessageHandler> = listOf(
66+
ContentScopeHandler(),
67+
breakageHandler,
68+
DuckPlayerHandler(),
69+
DuckChatHandler(),
70+
SubscriptionsHandler(),
71+
)
6672

6773
@JavascriptInterface
6874
override fun process(message: String, secret: String) {
@@ -166,4 +172,20 @@ class ContentScopeScriptsJsMessaging @Inject constructor(
166172
"openAIChatSettings",
167173
)
168174
}
175+
176+
inner class SubscriptionsHandler : JsMessageHandler {
177+
override fun process(jsMessage: JsMessage, secret: String, jsMessageCallback: JsMessageCallback?) {
178+
jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id ?: "", jsMessage.params)
179+
}
180+
181+
override val allowedDomains: List<String> = listOf(
182+
AppUrl.Url.HOST,
183+
)
184+
185+
override val featureName: String = "subscriptions"
186+
override val methods: List<String> = listOf(
187+
"handshake",
188+
"subscriptionDetails",
189+
)
190+
}
169191
}

content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,76 @@ class ContentScopeScriptsJsMessagingTest {
278278
assertEquals(0, callback.counter)
279279
}
280280

281+
@Test
282+
fun whenProcessSubscriptionsMessageWithHandshakeMethodThenCallbackExecuted() = runTest {
283+
givenInterfaceIsRegistered()
284+
whenever(mockWebView.url).thenReturn("https://duckduckgo.com")
285+
286+
val message = """
287+
{"context":"contentScopeScripts","featureName":"subscriptions","id":"myId","method":"handshake"}
288+
""".trimIndent()
289+
290+
contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret)
291+
292+
assertEquals(1, callback.counter)
293+
}
294+
295+
@Test
296+
fun whenProcessSubscriptionsMessageWithSubscriptionDetailsMethodThenCallbackExecuted() = runTest {
297+
givenInterfaceIsRegistered()
298+
whenever(mockWebView.url).thenReturn("https://duckduckgo.com")
299+
300+
val message = """
301+
{"context":"contentScopeScripts","featureName":"subscriptions","id":"myId","method":"subscriptionDetails"}
302+
""".trimIndent()
303+
304+
contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret)
305+
306+
assertEquals(1, callback.counter)
307+
}
308+
309+
@Test
310+
fun whenProcessSubscriptionsMessageWithUnknownMethodThenDoNothing() = runTest {
311+
givenInterfaceIsRegistered()
312+
whenever(mockWebView.url).thenReturn("https://duckduckgo.com")
313+
314+
val message = """
315+
{"context":"contentScopeScripts","featureName":"subscriptions","id":"myId","method":"unknownMethod"}
316+
""".trimIndent()
317+
318+
contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret)
319+
320+
assertEquals(0, callback.counter)
321+
}
322+
323+
@Test
324+
fun whenProcessSubscriptionsMessageWithInvalidFeatureNameThenDoNothing() = runTest {
325+
givenInterfaceIsRegistered()
326+
whenever(mockWebView.url).thenReturn("https://duckduckgo.com")
327+
328+
val message = """
329+
{"context":"contentScopeScripts","featureName":"invalidFeature","id":"myId","method":"handshake"}
330+
""".trimIndent()
331+
332+
contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret)
333+
334+
assertEquals(0, callback.counter)
335+
}
336+
337+
@Test
338+
fun whenProcessSubscriptionsMessageWithInvalidDomainThenDoNothing() = runTest {
339+
givenInterfaceIsRegistered()
340+
whenever(mockWebView.url).thenReturn("https://invalid-domain.com")
341+
342+
val message = """
343+
{"context":"contentScopeScripts","featureName":"subscriptions","id":"myId","method":"subscriptionDetails"}
344+
""".trimIndent()
345+
346+
contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret)
347+
348+
assertEquals(0, callback.counter)
349+
}
350+
281351
private val callback = object : JsMessageCallback() {
282352
var counter = 0
283353
override fun process(featureName: String, method: String, id: String?, data: JSONObject?) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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.subscriptions.impl.messaging
18+
19+
import com.duckduckgo.di.scopes.AppScope
20+
import com.duckduckgo.js.messaging.api.JsCallbackData
21+
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
22+
import com.squareup.anvil.annotations.ContributesBinding
23+
import javax.inject.Inject
24+
import org.json.JSONArray
25+
import org.json.JSONObject
26+
27+
interface SubscriptionsJSHelper {
28+
suspend fun processJsCallbackMessage(
29+
featureName: String,
30+
method: String,
31+
id: String?,
32+
data: JSONObject?,
33+
): JsCallbackData?
34+
}
35+
36+
@ContributesBinding(AppScope::class)
37+
class RealSubscriptionsJSHelper @Inject constructor(
38+
private val subscriptionsManager: SubscriptionsManager,
39+
) : SubscriptionsJSHelper {
40+
41+
override suspend fun processJsCallbackMessage(
42+
featureName: String,
43+
method: String,
44+
id: String?,
45+
data: JSONObject?,
46+
): JsCallbackData? = when (method) {
47+
METHOD_HANDSHAKE -> id?.let {
48+
val jsonPayload = JSONObject().apply {
49+
put(AVAILABLE_MESSAGES, JSONArray().put(SUBSCRIPTION_DETAILS))
50+
put(PLATFORM, ANDROID)
51+
}
52+
return JsCallbackData(jsonPayload, featureName, method, id)
53+
}
54+
55+
METHOD_SUBSCRIPTION_DETAILS -> id?.let {
56+
getSubscriptionDetailsData(featureName, method, it)
57+
}
58+
59+
else -> null
60+
}
61+
62+
private suspend fun getSubscriptionDetailsData(featureName: String, method: String, id: String): JsCallbackData {
63+
val jsonPayload = subscriptionsManager.getSubscription()?.let { userSubscription ->
64+
JSONObject().apply {
65+
put(IS_SUBSCRIBED, userSubscription.isActive())
66+
put(BILLING_PERIOD, userSubscription.billingPeriod)
67+
put(STARTED_AT, userSubscription.startedAt)
68+
put(EXPIRES_OR_RENEWS_AT, userSubscription.expiresOrRenewsAt)
69+
put(PAYMENT_PLATFORM, userSubscription.platform)
70+
put(STATUS, userSubscription.status.statusName)
71+
}
72+
} ?: JSONObject().apply {
73+
put(IS_SUBSCRIBED, false)
74+
}
75+
76+
return JsCallbackData(jsonPayload, featureName, method, id)
77+
}
78+
79+
companion object {
80+
const val SUBSCRIPTIONS_FEATURE_NAME = "subscriptions"
81+
private const val METHOD_HANDSHAKE = "handshake"
82+
private const val METHOD_SUBSCRIPTION_DETAILS = "subscriptionDetails"
83+
private const val AVAILABLE_MESSAGES = "availableMessages"
84+
private const val SUBSCRIPTION_DETAILS = "subscriptionDetails"
85+
private const val PLATFORM = "platform"
86+
private const val ANDROID = "android"
87+
private const val IS_SUBSCRIBED = "isSubscribed"
88+
private const val BILLING_PERIOD = "billingPeriod"
89+
private const val STARTED_AT = "startedAt"
90+
private const val EXPIRES_OR_RENEWS_AT = "expiresOrRenewsAt"
91+
private const val PAYMENT_PLATFORM = "paymentPlatform"
92+
private const val STATUS = "status"
93+
}
94+
}

0 commit comments

Comments
 (0)