Skip to content

Commit

Permalink
Intercept and pre-fill AI prompts (#5502)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1200204095367872/1209195652342399/f

### Description

- Intercepts AI chat URLs and opens them in the dedicated WebView.
- Pre-fills AI chat prompts from the query param in the chat URL.
- Adds an `autoPrompt` option to automatically start the chat.

### Steps to test this PR

_aiChat enabled_
- [x] In the feature flag inventory enable `aiChat` and restart the app.
- [x] Paste
https://duckduckgo.com/?q=bread+recipe&t=newext&atb=v345-1&ia=chat in
the address bar.
- [x] Verify that the chat screen is opened with “bread recipe"
pre-filled.
- [x] Go to https://leather-hickory-utahceratops.glitch.me/ and click
the link.
- [x] Verify that the chat screen is opened with “bread recipe"
pre-filled.

_aiChat disabled_
- [x] In the feature flag inventory disable `aiChat` and restart the
app.
- [x] Paste
https://duckduckgo.com/?q=bread+recipe&t=newext&atb=v345-1&ia=chat in
the address bar.
- [x] Verify that chat is opened in SERP.
- [x] Go to https://leather-hickory-utahceratops.glitch.me/ and click
the link.
- [x] Verify that chat is opened in SERP.
  • Loading branch information
joshliebe authored Jan 22, 2025
1 parent 4feab0f commit 33dabba
Show file tree
Hide file tree
Showing 11 changed files with 268 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5648,6 +5648,24 @@ class BrowserTabViewModelTest {
whenever(mockTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo()).thenReturn(inactive3w)
}

@Test
fun whenSubmittedQueryIsDuckChatLinkThenOpenDuckChat() {
whenever(mockSpecialUrlDetector.determineType(anyString())).thenReturn(SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink)
whenever(mockOmnibarConverter.convertQueryToUrl("https://duckduckgo.com/?q=example&ia=chat&duckai=5", null)).thenReturn(
"https://duckduckgo.com/?q=example&ia=chat&duckai=5",
)
testee.onUserSubmittedQuery("https://duckduckgo.com/?q=example&ia=chat&duckai=5")
mockDuckChat.openDuckChat("example")
}

@Test
fun whenSubmittedQueryIsDuckChatLinkWithoutQueryThenOpenDuckChatWithoutQuery() {
whenever(mockSpecialUrlDetector.determineType(anyString())).thenReturn(SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink)
whenever(mockOmnibarConverter.convertQueryToUrl("https://duckduckgo.com/?ia=chat", null)).thenReturn("https://duckduckgo.com/?ia=chat")
testee.onUserSubmittedQuery("https://duckduckgo.com/?ia=chat")
mockDuckChat.openDuckChat()
}

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 @@ -75,6 +75,7 @@ import com.duckduckgo.common.utils.CurrentTimeProvider
import com.duckduckgo.common.utils.device.DeviceInfo
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.cookies.api.CookieManagerProvider
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckplayer.api.DuckPlayer
import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.SERP_AUTO
import com.duckduckgo.duckplayer.api.DuckPlayer.OpenDuckPlayerInNewTab
Expand Down Expand Up @@ -164,6 +165,7 @@ class BrowserWebViewClientTest {
mock(),
)
private val mockMaliciousSiteProtection: MaliciousSiteBlockerWebViewIntegration = mock()
private val mockDuckChat: DuckChat = mock()

@UiThreadTest
@Before
Expand Down Expand Up @@ -201,6 +203,7 @@ class BrowserWebViewClientTest {
mockDuckDuckGoUrlDetector,
mockUriLoadedManager,
mockAndroidFeaturesHeaderPlugin,
mockDuckChat,
)
testee.webViewClientListener = listener
whenever(webResourceRequest.url).thenReturn(Uri.EMPTY)
Expand Down Expand Up @@ -425,6 +428,38 @@ class BrowserWebViewClientTest {
verify(subscriptions).launchPrivacyPro(any(), any())
}

@UiThreadTest
@Test
fun whenDuckChatLinkDetectedThenLaunchDuckChatAndReturnTrue() {
val urlType = SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink
whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any())).thenReturn(urlType)
whenever(webResourceRequest.url).thenReturn("https://duckduckgo.com/?q=example&ia=chat&duckai=5".toUri())
assertTrue(testee.shouldOverrideUrlLoading(webView, webResourceRequest))
verify(mockDuckChat).openDuckChat("example")
}

@UiThreadTest
@Test
fun whenDuckChatLinkDetectedWithoutQueryThenLaunchDuckChatWithoutQueryAndReturnTrue() {
val urlType = SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink
whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any())).thenReturn(urlType)
whenever(webResourceRequest.url).thenReturn("https://duckduckgo.com/?ia=chat".toUri())
assertTrue(testee.shouldOverrideUrlLoading(webView, webResourceRequest))
verify(mockDuckChat).openDuckChat()
}

@UiThreadTest
@Test
fun whenDuckChatLinkDetectedAndExceptionThrownThenDoNotLaunchDuckChatAndReturnFalse() {
val urlType = SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink
whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any())).thenReturn(urlType)
val mockUri: Uri = mock()
whenever(mockUri.getQueryParameter(anyString())).thenThrow(RuntimeException())
whenever(webResourceRequest.url).thenReturn(mockUri)
assertFalse(testee.shouldOverrideUrlLoading(webView, webResourceRequest))
verifyNoInteractions(mockDuckChat)
}

@UiThreadTest
@Test
fun whenShouldOverrideWithShouldNavigateToDuckPlayerSetOriginToSerpAuto() = runTest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import com.duckduckgo.app.browser.SSLErrorType.UNTRUSTED_HOST
import com.duckduckgo.app.browser.SSLErrorType.WRONG_HOST
import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.AppLink
import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.NonHttpAppLink
import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink
import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.ShouldLaunchPrivacyProLink
import com.duckduckgo.app.browser.WebViewErrorResponse.LOADING
import com.duckduckgo.app.browser.WebViewErrorResponse.OMITTED
Expand Down Expand Up @@ -262,6 +263,7 @@ import com.duckduckgo.browser.api.brokensite.BrokenSiteData
import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.MENU
import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.RELOAD_THREE_TIMES_WITHIN_20_SECONDS
import com.duckduckgo.common.utils.AppUrl
import com.duckduckgo.common.utils.AppUrl.ParamKey.QUERY
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.SingleLiveEvent
Expand Down Expand Up @@ -1011,6 +1013,13 @@ class BrowserTabViewModel @Inject constructor(
var urlToNavigate = queryUrlConverter.convertQueryToUrl(trimmedInput, verticalParameter, queryOrigin)

when (val type = specialUrlDetector.determineType(trimmedInput)) {
is ShouldLaunchDuckChatLink -> {
runCatching {
duckChat.openDuckChat(urlToNavigate.toUri().getQueryParameter(QUERY))
return
}
}

is ShouldLaunchPrivacyProLink -> {
if (webNavigationState == null || webNavigationState?.hasNavigationHistory == false) {
closeCurrentTab()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ import com.duckduckgo.autoconsent.api.Autoconsent
import com.duckduckgo.autofill.api.BrowserAutofill
import com.duckduckgo.autofill.api.InternalTestUserChecker
import com.duckduckgo.browser.api.JsInjectorPlugin
import com.duckduckgo.common.utils.AppUrl.ParamKey.QUERY
import com.duckduckgo.common.utils.CurrentTimeProvider
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.cookies.api.CookieManagerProvider
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckplayer.api.DuckPlayer
import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.SERP_AUTO
import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED
Expand Down Expand Up @@ -117,6 +119,7 @@ class BrowserWebViewClient @Inject constructor(
private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector,
private val uriLoadedManager: UriLoadedManager,
private val androidFeaturesHeaderPlugin: AndroidFeaturesHeaderPlugin,
private val duckChat: DuckChat,
) : WebViewClient() {

var webViewClientListener: WebViewClientListener? = null
Expand Down Expand Up @@ -199,6 +202,11 @@ class BrowserWebViewClient @Inject constructor(
}
false
}
is SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink -> {
runCatching {
duckChat.openDuckChat(url.getQueryParameter(QUERY))
}.isSuccess
}
is SpecialUrlDetector.UrlType.ShouldLaunchDuckPlayerLink -> {
if (isRedirect && isForMainFrame) {
/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,13 @@ import android.net.Uri
import androidx.core.net.toUri
import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType
import com.duckduckgo.app.browser.applinks.ExternalAppIntentFlagsFeature
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckplayer.api.DuckPlayer
import com.duckduckgo.privacy.config.api.AmpLinkType
import com.duckduckgo.privacy.config.api.AmpLinks
import com.duckduckgo.privacy.config.api.TrackingParameters
import com.duckduckgo.subscriptions.api.Subscriptions
import java.net.URISyntaxException
import kotlinx.coroutines.CoroutineScope
import timber.log.Timber

class SpecialUrlDetectorImpl(
Expand All @@ -43,8 +42,7 @@ class SpecialUrlDetectorImpl(
private val subscriptions: Subscriptions,
private val externalAppIntentFlagsFeature: ExternalAppIntentFlagsFeature,
private val duckPlayer: DuckPlayer,
private val scope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val duckChat: DuckChat,
) : SpecialUrlDetector {

override fun determineType(initiatingUrl: String?, uri: Uri): UrlType {
Expand Down Expand Up @@ -90,6 +88,10 @@ class SpecialUrlDetectorImpl(

val uri = uriString.toUri()

if (duckChat.isEnabled() && duckChat.isDuckChatUrl(uri)) {
return UrlType.ShouldLaunchDuckChatLink
}

if (duckPlayer.willNavigateToDuckPlayer(uri)) {
return UrlType.ShouldLaunchDuckPlayerLink(url = uri)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import com.duckduckgo.downloads.api.FileDownloader
import com.duckduckgo.downloads.impl.AndroidFileDownloader
import com.duckduckgo.downloads.impl.DataUriDownloader
import com.duckduckgo.downloads.impl.FileDownloadCallback
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckplayer.api.DuckPlayer
import com.duckduckgo.experiments.api.VariantManager
import com.duckduckgo.httpsupgrade.api.HttpsUpgrader
Expand Down Expand Up @@ -177,17 +178,15 @@ class BrowserModule {
subscriptions: Subscriptions,
externalAppIntentFlagsFeature: ExternalAppIntentFlagsFeature,
duckPlayer: DuckPlayer,
@AppCoroutineScope appCoroutineScope: CoroutineScope,
dispatcherProvider: DispatcherProvider,
duckChat: DuckChat,
): SpecialUrlDetector = SpecialUrlDetectorImpl(
packageManager,
ampLinks,
trackingParameters,
subscriptions,
externalAppIntentFlagsFeature,
duckPlayer,
appCoroutineScope,
dispatcherProvider = dispatcherProvider,
duckChat,
)

@Provides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import com.duckduckgo.app.browser.SpecialUrlDetectorImpl.Companion.EMAIL_MAX_LEN
import com.duckduckgo.app.browser.SpecialUrlDetectorImpl.Companion.PHONE_MAX_LENGTH
import com.duckduckgo.app.browser.SpecialUrlDetectorImpl.Companion.SMS_MAX_LENGTH
import com.duckduckgo.app.browser.applinks.ExternalAppIntentFlagsFeature
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckplayer.api.DuckPlayer
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.privacy.config.api.AmpLinkType
Expand All @@ -41,7 +41,6 @@ import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyInt
Expand All @@ -53,9 +52,6 @@ class SpecialUrlDetectorImplTest {

lateinit var testee: SpecialUrlDetector

@get:Rule
var coroutineRule = CoroutineTestRule()

val mockPackageManager: PackageManager = mock()

val mockAmpLinks: AmpLinks = mock()
Expand All @@ -70,6 +66,8 @@ class SpecialUrlDetectorImplTest {

val mockDuckPlayer: DuckPlayer = mock()

val mockDuckChat: DuckChat = mock()

@Before
fun setup() = runTest {
testee = SpecialUrlDetectorImpl(
Expand All @@ -79,8 +77,7 @@ class SpecialUrlDetectorImplTest {
subscriptions = subscriptions,
externalAppIntentFlagsFeature = externalAppIntentFlagsFeature,
duckPlayer = mockDuckPlayer,
scope = coroutineRule.testScope,
dispatcherProvider = coroutineRule.testDispatcherProvider,
duckChat = mockDuckChat,
)
whenever(mockPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(emptyList())
whenever(mockDuckPlayer.willNavigateToDuckPlayer(any())).thenReturn(false)
Expand Down Expand Up @@ -448,6 +445,38 @@ class SpecialUrlDetectorImplTest {
assertTrue(actual is ShouldLaunchPrivacyProLink)
}

@Test
fun whenDuckChatIsEnabledAndIsDuckChatUrlThenReturnShouldLaunchDuckChatLink() = runTest {
whenever(mockDuckChat.isEnabled()).thenReturn(true)
whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true)
val type = testee.determineType("https://example.com")
whenever(mockPackageManager.resolveActivity(any(), eq(PackageManager.MATCH_DEFAULT_ONLY))).thenReturn(null)
whenever(mockPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(
listOf(
buildAppResolveInfo(),
buildBrowserResolveInfo(),
ResolveInfo(),
),
)
assertTrue(type is ShouldLaunchDuckChatLink)
}

@Test
fun whenDuckChatIsDisabledAndIsDuckChatUrlThenDoNotReturnShouldLaunchDuckChatLink() = runTest {
whenever(mockDuckChat.isEnabled()).thenReturn(false)
whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true)
val type = testee.determineType("https://example.com")
whenever(mockPackageManager.resolveActivity(any(), eq(PackageManager.MATCH_DEFAULT_ONLY))).thenReturn(null)
whenever(mockPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(
listOf(
buildAppResolveInfo(),
buildBrowserResolveInfo(),
ResolveInfo(),
),
)
assertTrue(type !is ShouldLaunchDuckChatLink)
}

private fun randomString(length: Int): String {
val charList: List<Char> = ('a'..'z') + ('0'..'9')
return List(length) { charList.random() }.joinToString("")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,6 @@ interface SpecialUrlDetector {
data object ShouldLaunchPrivacyProLink : UrlType()
data class ShouldLaunchDuckPlayerLink(val url: Uri) : UrlType()
class DuckScheme(val uriString: String) : UrlType()
data object ShouldLaunchDuckChatLink : UrlType()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,19 @@

package com.duckduckgo.duckchat.api

import android.net.Uri

/**
* DuckChat interface provides a set of methods for interacting and controlling DuckChat.
*/
interface DuckChat {
/**
* Checks whether DuckChat is enabled based on remote config flag.
* Sets IO dispatcher.
* Uses a cached value - does not perform disk I/O.
*
* @return true if DuckChat is enabled, false otherwise.
*/
suspend fun isEnabled(): Boolean
fun isEnabled(): Boolean

/**
* Checks whether DuckChat should be shown in browser menu based on user settings.
Expand All @@ -37,7 +39,19 @@ interface DuckChat {
fun showInBrowserMenu(): Boolean

/**
* Opens the DuckChat WebView.
* Opens the DuckChat WebView with optional pre-filled [String] query.
*/
fun openDuckChat(query: String? = null)

/**
* Auto-prompts the DuckChat WebView with the provided [String] query.
*/
fun openDuckChatWithAutoPrompt(query: String)

/**
* Determines whether a given [Uri] is a DuckChat URL.
*
* @return true if it is a DuckChat URL, false otherwise.
*/
fun openDuckChat()
fun isDuckChatUrl(uri: Uri): Boolean
}
Loading

0 comments on commit 33dabba

Please sign in to comment.