diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 3a32e64a8f35..e71157697729 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -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) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index 84781ec36cf9..6dadd2047d77 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -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 @@ -164,6 +165,7 @@ class BrowserWebViewClientTest { mock(), ) private val mockMaliciousSiteProtection: MaliciousSiteBlockerWebViewIntegration = mock() + private val mockDuckChat: DuckChat = mock() @UiThreadTest @Before @@ -201,6 +203,7 @@ class BrowserWebViewClientTest { mockDuckDuckGoUrlDetector, mockUriLoadedManager, mockAndroidFeaturesHeaderPlugin, + mockDuckChat, ) testee.webViewClientListener = listener whenever(webResourceRequest.url).thenReturn(Uri.EMPTY) @@ -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 { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index a76d72991f72..653a05c425fb 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -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 @@ -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 @@ -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() diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index f8ba3fb39761..1397de8adb38 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -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 @@ -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 @@ -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) { /* diff --git a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt index 4995b74b6f32..07bdd2e7d103 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt @@ -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( @@ -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 { @@ -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 { diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index ca07b2c52796..6c8e20972cf7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -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 @@ -177,8 +178,7 @@ class BrowserModule { subscriptions: Subscriptions, externalAppIntentFlagsFeature: ExternalAppIntentFlagsFeature, duckPlayer: DuckPlayer, - @AppCoroutineScope appCoroutineScope: CoroutineScope, - dispatcherProvider: DispatcherProvider, + duckChat: DuckChat, ): SpecialUrlDetector = SpecialUrlDetectorImpl( packageManager, ampLinks, @@ -186,8 +186,7 @@ class BrowserModule { subscriptions, externalAppIntentFlagsFeature, duckPlayer, - appCoroutineScope, - dispatcherProvider = dispatcherProvider, + duckChat, ) @Provides diff --git a/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt b/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt index 38d484a2e192..dfdd8a4af221 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt @@ -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 @@ -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 @@ -53,9 +52,6 @@ class SpecialUrlDetectorImplTest { lateinit var testee: SpecialUrlDetector - @get:Rule - var coroutineRule = CoroutineTestRule() - val mockPackageManager: PackageManager = mock() val mockAmpLinks: AmpLinks = mock() @@ -70,6 +66,8 @@ class SpecialUrlDetectorImplTest { val mockDuckPlayer: DuckPlayer = mock() + val mockDuckChat: DuckChat = mock() + @Before fun setup() = runTest { testee = SpecialUrlDetectorImpl( @@ -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) @@ -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 = ('a'..'z') + ('0'..'9') return List(length) { charList.random() }.joinToString("") diff --git a/browser-api/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt b/browser-api/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt index 31b255ae2e16..711897d819b8 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt @@ -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() } } diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt index da29cfec296f..f2f5f379424f 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt @@ -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. @@ -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 } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index d15d8ecc0b51..2b256b4ec06b 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -18,10 +18,13 @@ package com.duckduckgo.duckchat.impl import android.content.Context import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.di.IsMainProcess import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams +import com.duckduckgo.common.utils.AppUrl.ParamKey.QUERY import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.api.DuckChat @@ -76,6 +79,9 @@ class RealDuckChat @Inject constructor( moshi.adapter(DuckChatSettingJson::class.java) } + /** Cached DuckChat is enabled flag */ + private var isDuckChatEnabled = false + /** Cached value of whether we should show DuckChat in the menu or not */ private var showInBrowserMenu = false @@ -105,8 +111,8 @@ class RealDuckChat @Inject constructor( cacheShowInBrowser() } - override suspend fun isEnabled(): Boolean = withContext(dispatchers.io()) { - duckChatFeature.self().isEnabled() + override fun isEnabled(): Boolean { + return isDuckChatEnabled } override fun observeShowInBrowserMenuUserSetting(): Flow { @@ -117,12 +123,32 @@ class RealDuckChat @Inject constructor( return showInBrowserMenu } - override fun openDuckChat() { + override fun openDuckChat(query: String?) { + val parameters = query?.let { + mapOf(QUERY to it) + } ?: emptyMap() + openDuckChat(parameters) + } + + override fun openDuckChatWithAutoPrompt(query: String) { + val parameters = mapOf( + QUERY to query, + PROMPT_QUERY_NAME to PROMPT_QUERY_VALUE, + ) + openDuckChat(parameters) + } + + private fun openDuckChat(parameters: Map) { pixel.fire(DuckChatPixelName.DUCK_CHAT_OPEN) + val url = appendParameters(parameters, duckChatLink) + startDuckChatActivity(url) + } + + private fun startDuckChatActivity(url: String) { val intent = globalActivityStarter.startIntent( context, WebViewActivityWithParams( - url = duckChatLink, + url = url, screenTitle = context.getString(R.string.duck_chat_title), supportNewWindows = true, ), @@ -134,6 +160,35 @@ class RealDuckChat @Inject constructor( } } + private fun appendParameters( + parameters: Map, + url: String, + ): String { + if (parameters.isEmpty()) return url + return runCatching { + val uri = url.toUri() + uri.buildUpon().apply { + clearQuery() + parameters.forEach { (key, value) -> + appendQueryParameter(key, value) + } + uri.queryParameterNames + .filterNot { it in parameters.keys } + .forEach { appendQueryParameter(it, uri.getQueryParameter(it)) } + }.build().toString() + }.getOrElse { url } + } + + override fun isDuckChatUrl(uri: Uri): Boolean { + if (uri.host != DUCKDUCKGO_HOST) { + return false + } + return runCatching { + val queryParameters = uri.queryParameterNames + queryParameters.contains(CHAT_QUERY_NAME) && uri.getQueryParameter(CHAT_QUERY_NAME) == CHAT_QUERY_VALUE + }.getOrDefault(false) + } + private fun cacheDuckChatLink() { appCoroutineScope.launch(dispatchers.io()) { duckChatLink = duckChatFeature.self().getSettings()?.let { @@ -147,12 +202,18 @@ class RealDuckChat @Inject constructor( private fun cacheShowInBrowser() { appCoroutineScope.launch(dispatchers.io()) { - showInBrowserMenu = duckChatFeatureRepository.shouldShowInBrowserMenu() && duckChatFeature.self().isEnabled() + isDuckChatEnabled = duckChatFeature.self().isEnabled() + showInBrowserMenu = duckChatFeatureRepository.shouldShowInBrowserMenu() && isDuckChatEnabled } } companion object { /** Default link to DuckChat that identifies Android as the source */ private const val DUCK_CHAT_WEB_LINK = "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5" + private const val DUCKDUCKGO_HOST = "duckduckgo.com" + private const val CHAT_QUERY_NAME = "ia" + private const val CHAT_QUERY_VALUE = "chat" + private const val PROMPT_QUERY_NAME = "prompt" + private const val PROMPT_QUERY_VALUE = "1" } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt index 58aafecf4b60..6e552c047435 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt @@ -17,8 +17,11 @@ package com.duckduckgo.duckchat.impl import android.content.Context +import android.content.Intent +import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.navigation.api.GlobalActivityStarter @@ -51,6 +54,7 @@ class RealDuckChatTest { private val mockGlobalActivityStarter: GlobalActivityStarter = mock() private val mockContext: Context = mock() private val mockPixel: Pixel = mock() + private val mockIntent: Intent = mock() private val testee = RealDuckChat( mockDuckPlayerFeatureRepository, @@ -69,6 +73,7 @@ class RealDuckChatTest { whenever(mockDuckPlayerFeatureRepository.shouldShowInBrowserMenu()).thenReturn(true) whenever(mockContext.getString(any())).thenReturn("Duck.ai") setFeatureToggle(true) + whenever(mockGlobalActivityStarter.startIntent(any(), any())).thenReturn(mockIntent) } @Test @@ -126,6 +131,69 @@ class RealDuckChatTest { verify(mockPixel).fire(DuckChatPixelName.DUCK_CHAT_OPEN) } + @Test + fun whenOpenDuckChatWithAutoPromptCalled_pixelIsSent() { + testee.openDuckChatWithAutoPrompt("example") + verify(mockPixel).fire(DuckChatPixelName.DUCK_CHAT_OPEN) + } + + @Test + fun whenOpenDuckChatCalled_activityStarted() { + testee.openDuckChat() + verify(mockGlobalActivityStarter).startIntent( + mockContext, + WebViewActivityWithParams( + url = "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5", + screenTitle = "Duck.ai", + supportNewWindows = true, + ), + ) + verify(mockContext).startActivity(any()) + } + + @Test + fun whenOpenDuckChatCalledWithQuery_activityStartedWithQuery() { + testee.openDuckChat(query = "example") + verify(mockGlobalActivityStarter).startIntent( + mockContext, + WebViewActivityWithParams( + url = "https://duckduckgo.com/?q=example&ia=chat&duckai=5", + screenTitle = "Duck.ai", + supportNewWindows = true, + ), + ) + verify(mockContext).startActivity(any()) + } + + @Test + fun whenOpenDuckChatCalledWithQueryAndAutoPrompt_activityStartedWithQueryAndAutoPrompt() { + testee.openDuckChatWithAutoPrompt(query = "example") + verify(mockGlobalActivityStarter).startIntent( + mockContext, + WebViewActivityWithParams( + url = "https://duckduckgo.com/?q=example&prompt=1&ia=chat&duckai=5", + screenTitle = "Duck.ai", + supportNewWindows = true, + ), + ) + verify(mockContext).startActivity(any()) + } + + @Test + fun whenIsDuckDuckGoHostAndDuckChatEnabledAndIsDuckChatLink_isDuckChatUrl() { + assertTrue(testee.isDuckChatUrl("https://duckduckgo.com/?ia=chat".toUri())) + } + + @Test + fun whenIsDuckDuckGoHostAndDuckChatEnabledAndIsNotDuckChatLink_isNotDuckChatUrl() { + assertFalse(testee.isDuckChatUrl("https://duckduckgo.com/?q=test".toUri())) + } + + @Test + fun whenIsNotDuckDuckGoHostAndDuckChatEnabled_isNotDuckChatUrl() { + assertFalse(testee.isDuckChatUrl("https://example.com/?ia=chat".toUri())) + } + private fun setFeatureToggle(enabled: Boolean) { duckChatFeature.self().setRawStoredState(State(enabled)) testee.onPrivacyConfigDownloaded()