Skip to content

Commit

Permalink
Add AI Chat SERP integration
Browse files Browse the repository at this point in the history
  • Loading branch information
joshliebe committed Jan 24, 2025
1 parent 63e7261 commit c2a1d2f
Show file tree
Hide file tree
Showing 10 changed files with 593 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ import com.duckduckgo.app.browser.commands.Command.ShowBackNavigationHistory
import com.duckduckgo.app.browser.commands.NavigationCommand
import com.duckduckgo.app.browser.commands.NavigationCommand.Navigate
import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames
import com.duckduckgo.app.browser.duckchat.DuckChatJSHelper
import com.duckduckgo.app.browser.duckchat.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME
import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME
import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_PAGE_FEATURE_NAME
import com.duckduckgo.app.browser.duckplayer.DuckPlayerJSHelper
Expand Down Expand Up @@ -218,6 +220,7 @@ import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.feature.toggles.api.Toggle.State
import com.duckduckgo.history.api.HistoryEntry.VisitedPage
import com.duckduckgo.history.api.NavigationHistory
import com.duckduckgo.js.messaging.api.JsCallbackData
import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels
import com.duckduckgo.privacy.config.api.AmpLinkInfo
import com.duckduckgo.privacy.config.api.AmpLinks
Expand Down Expand Up @@ -504,6 +507,7 @@ class BrowserTabViewModelTest {
private val extendedOnboardingFeatureToggles = FeatureToggles.Builder(FakeToggleStore(), featureName = "extendedOnboarding").build()
.create(ExtendedOnboardingFeatureToggles::class.java)
private val extendedOnboardingPixelsPlugin = ExtendedOnboardingPixelsPlugin(extendedOnboardingFeatureToggles)
private val mockDuckChatJSHelper: DuckChatJSHelper = mock()

@Before
fun before() = runTest {
Expand Down Expand Up @@ -665,6 +669,7 @@ class BrowserTabViewModelTest {
duckPlayer = mockDuckPlayer,
duckChat = mockDuckChat,
duckPlayerJSHelper = DuckPlayerJSHelper(mockDuckPlayer, mockAppBuildConfig, mockPixel, mockDuckDuckGoUrlDetector),
duckChatJSHelper = mockDuckChatJSHelper,
refreshPixelSender = refreshPixelSender,
changeOmnibarPositionFeature = changeOmnibarPositionFeature,
highlightsOnboardingExperimentManager = mockHighlightsOnboardingExperimentManager,
Expand Down Expand Up @@ -5666,6 +5671,37 @@ class BrowserTabViewModelTest {
mockDuckChat.openDuckChat()
}

@Test
fun whenProcessJsCallbackMessageForDuckChatThenSendCommand() = runTest {
whenever(mockEnabledToggle.isEnabled()).thenReturn(true)
val sendResponseToJs = Command.SendResponseToJs(JsCallbackData(JSONObject(), "", "", ""))
whenever(mockDuckChatJSHelper.processJsCallbackMessage(anyString(), anyString(), anyOrNull(), anyOrNull())).thenReturn(sendResponseToJs)
testee.processJsCallbackMessage(
DUCK_CHAT_FEATURE_NAME,
"method",
"id",
data = null,
false,
) { "someUrl" }
verify(mockDuckChatJSHelper).processJsCallbackMessage(DUCK_CHAT_FEATURE_NAME, "method", "id", null)
assertCommandIssued<Command.SendResponseToJs>()
}

@Test
fun whenProcessJsCallbackMessageForDuckChatAndResponseIsNullThenDoNotSendCommand() = runTest {
whenever(mockEnabledToggle.isEnabled()).thenReturn(true)
whenever(mockDuckChatJSHelper.processJsCallbackMessage(anyString(), anyString(), anyOrNull(), anyOrNull())).thenReturn(null)
testee.processJsCallbackMessage(
DUCK_CHAT_FEATURE_NAME,
"method",
"id",
data = null,
false,
) { "someUrl" }
verify(mockDuckChatJSHelper).processJsCallbackMessage(DUCK_CHAT_FEATURE_NAME, "method", "id", null)
assertCommandNotIssued<Command.SendResponseToJs>()
}

private fun aCredential(): LoginCredentials {
return LoginCredentials(domain = null, username = null, password = null)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ import com.duckduckgo.app.browser.commands.Command.WebShareRequest
import com.duckduckgo.app.browser.commands.Command.WebViewError
import com.duckduckgo.app.browser.commands.NavigationCommand
import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames
import com.duckduckgo.app.browser.duckchat.DuckChatJSHelper
import com.duckduckgo.app.browser.duckchat.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME
import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME
import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_PAGE_FEATURE_NAME
import com.duckduckgo.app.browser.duckplayer.DuckPlayerJSHelper
Expand Down Expand Up @@ -429,6 +431,7 @@ class BrowserTabViewModel @Inject constructor(
private val duckPlayer: DuckPlayer,
private val duckChat: DuckChat,
private val duckPlayerJSHelper: DuckPlayerJSHelper,
private val duckChatJSHelper: DuckChatJSHelper,
private val refreshPixelSender: RefreshPixelSender,
private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature,
private val highlightsOnboardingExperimentManager: HighlightsOnboardingExperimentManager,
Expand Down Expand Up @@ -3263,6 +3266,17 @@ class BrowserTabViewModel @Inject constructor(
}
}

DUCK_CHAT_FEATURE_NAME -> {
viewModelScope.launch(dispatchers.io()) {
val response = duckChatJSHelper.processJsCallbackMessage(featureName, method, id, data)
withContext(dispatchers.main()) {
response?.let {
command.value = it
}
}
}
}

else -> {}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* 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.app.browser.duckchat

import com.duckduckgo.app.browser.commands.Command
import com.duckduckgo.app.browser.commands.Command.SendResponseToJs
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.js.messaging.api.JsCallbackData
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import org.json.JSONObject

interface DuckChatJSHelper {
suspend fun processJsCallbackMessage(
featureName: String,
method: String,
id: String?,
data: JSONObject?,
): Command?
}

@ContributesBinding(AppScope::class)
class RealDuckChatJSHelper @Inject constructor(
private val duckChat: DuckChat,
private val preferencesStore: DuckChatPreferencesStore,
) : DuckChatJSHelper {
private fun getUserValues(featureName: String, method: String, id: String): JsCallbackData {
val jsonPayload = JSONObject().apply {
put(PLATFORM, ANDROID)
put(IS_HANDOFF_ENABLED, duckChat.isEnabled())
put(PAYLOAD, preferencesStore.fetchAndClearUserPreferences())
}
return JsCallbackData(jsonPayload, featureName, method, id)
}

override suspend fun processJsCallbackMessage(
featureName: String,
method: String,
id: String?,
data: JSONObject?,
): Command? = when (method) {
METHOD_GET_USER_VALUES -> id?.let {
SendResponseToJs(getUserValues(featureName, method, it))
}
METHOD_OPEN_AI_CHAT -> {
data?.optString(PAYLOAD).let { payload ->
preferencesStore.updateUserPreferences(payload)
}
duckChat.openDuckChat()
null
}
else -> null
}

companion object {
const val DUCK_CHAT_FEATURE_NAME = "aiChat"
private const val METHOD_GET_USER_VALUES = "getUserValues"
private const val METHOD_OPEN_AI_CHAT = "openAIChat"
private const val PAYLOAD = "aiChatPayload"
private const val IS_HANDOFF_ENABLED = "isAIChatHandoffEnabled"
private const val PLATFORM = "platform"
private const val ANDROID = "android"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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.app.browser.duckchat

import androidx.core.content.edit
import com.duckduckgo.data.store.api.SharedPreferencesProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface DuckChatPreferencesStore {
fun fetchAndClearUserPreferences(): String?
fun updateUserPreferences(userPreferences: String?)
}

@ContributesBinding(AppScope::class)
class RealDuckChatPreferencesStore @Inject constructor(
private val sharedPreferencesProvider: SharedPreferencesProvider,
) : DuckChatPreferencesStore {

private val preferences by lazy {
sharedPreferencesProvider.getSharedPreferences(FILENAME)
}

override fun fetchAndClearUserPreferences(): String? =
preferences.getString(USER_PREFERENCES, null).also {
preferences.edit { remove(USER_PREFERENCES) }
}

override fun updateUserPreferences(userPreferences: String?) {
preferences.edit {
putString(USER_PREFERENCES, userPreferences)
}
}

companion object {
const val FILENAME = "com.duckduckgo.app.duckchat"
const val USER_PREFERENCES = "DUCK_CHAT_USER_PREFERENCES"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,28 @@ import com.duckduckgo.anvil.annotations.ContributeToActivityStarter
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.app.browser.BrowserActivity
import com.duckduckgo.app.browser.BrowserWebViewClient
import com.duckduckgo.app.browser.commands.Command.SendResponseToJs
import com.duckduckgo.app.browser.databinding.ActivityWebviewBinding
import com.duckduckgo.app.browser.duckchat.DuckChatJSHelper
import com.duckduckgo.app.browser.duckchat.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.pixels.AppPixelName
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams
import com.duckduckgo.common.ui.DuckDuckGoActivity
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.js.messaging.api.JsMessageCallback
import com.duckduckgo.js.messaging.api.JsMessaging
import com.duckduckgo.navigation.api.getActivityParams
import com.duckduckgo.user.agent.api.UserAgentProvider
import javax.inject.Inject
import javax.inject.Named
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject

@InjectWith(ActivityScope::class)
@ContributeToActivityStarter(WebViewActivityWithParams::class)
Expand All @@ -51,6 +63,20 @@ class WebViewActivity : DuckDuckGoActivity() {
@Inject
lateinit var pixel: Pixel

@Inject
@Named("ContentScopeScripts")
lateinit var contentScopeScripts: JsMessaging

@Inject
lateinit var duckChatJSHelper: DuckChatJSHelper

@Inject
@AppCoroutineScope
lateinit var appCoroutineScope: CoroutineScope

@Inject
lateinit var dispatcherProvider: DispatcherProvider

private val binding: ActivityWebviewBinding by viewBinding()

private val toolbar
Expand Down Expand Up @@ -106,6 +132,33 @@ class WebViewActivity : DuckDuckGoActivity() {
databaseEnabled = false
setSupportZoom(true)
}

contentScopeScripts.register(
it,
object : JsMessageCallback() {
override fun process(
featureName: String,
method: String,
id: String?,
data: JSONObject?,
) {
when (featureName) {
DUCK_CHAT_FEATURE_NAME -> {
appCoroutineScope.launch(dispatcherProvider.io()) {
val response = duckChatJSHelper.processJsCallbackMessage(featureName, method, id, data)
if (response is SendResponseToJs) {
withContext(dispatcherProvider.main()) {
contentScopeScripts.onResponse(response.data)
}
}
}
}

else -> {}
}
}
},
)
}

url?.let {
Expand Down
Loading

0 comments on commit c2a1d2f

Please sign in to comment.