Skip to content

Commit 7f67a80

Browse files
authored
Duck.ai on custom tabs UI (#8897)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1204912272578138/task/1215730567218052?focus=true Tech Design URL (if applicable): N/A ### Description Implement redirect for duck.ai links in custom tabs to Duck Chat experience. ### Steps to test this PR _Feature_ _1:_ _Open_ _duck.ai_ - [x] Install from this branch - [x] Skip onboarding, make DDG default browser - [x] Tap on https://duck.ai from an email - [x] Notice Duck.ai does not open in Custom Tab Feature 2: Open Reddit and open duck.ai from there - [x] Install from this branch - [x] Skip onboarding, make DDG default browser - [x] Tap on https://www.reddit.com/r/duckduckgo/comments/1u3uk82/duckai_usage_decrease_for_pro_subscription/ from an email - [x] Notice the page loads in a Custom Tab - [x] Tap on the duck.ai link in the page - [x] Notice Duck.ai does not open in Custom Tab ### NO UI changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a summary for commit 6e97328. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 38ab7d1 commit 7f67a80

10 files changed

Lines changed: 188 additions & 8 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2482,6 +2482,10 @@ class BrowserTabFragment :
24822482
(activity as? CustomTabActivity)?.finishAndRemoveTask()
24832483
}
24842484

2485+
private fun finishCustomTab() {
2486+
(activity as? CustomTabActivity)?.finish()
2487+
}
2488+
24852489
private fun onBypassMaliciousWarning(
24862490
url: Uri,
24872491
feed: Feed,
@@ -2954,6 +2958,7 @@ class BrowserTabFragment :
29542958
is Command.HideWarningMaliciousSite -> hideMaliciousWarning(it.canGoBack)
29552959
is Command.EscapeMaliciousSite -> onEscapeMaliciousSite()
29562960
is Command.CloseCustomTab -> closeCustomTab()
2961+
is Command.FinishCustomTab -> finishCustomTab()
29572962
is Command.BypassMaliciousSiteWarning -> onBypassMaliciousWarning(it.url, it.feed)
29582963
is OpenBrokenSiteLearnMore -> openBrokenSiteLearnMore(it.url)
29592964
is ReportBrokenSiteError -> openBrokenSiteReportError(it.url)

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ import com.duckduckgo.app.browser.commands.Command.EscapeMaliciousSite
9696
import com.duckduckgo.app.browser.commands.Command.ExtractSerpLogo
9797
import com.duckduckgo.app.browser.commands.Command.ExtractUrlFromCloakedAmpLink
9898
import com.duckduckgo.app.browser.commands.Command.FindInPageCommand
99+
import com.duckduckgo.app.browser.commands.Command.FinishCustomTab
99100
import com.duckduckgo.app.browser.commands.Command.GenerateWebViewPreviewImage
100101
import com.duckduckgo.app.browser.commands.Command.HandleNonHttpAppLink
101102
import com.duckduckgo.app.browser.commands.Command.HideBrokenSitePromptCta
@@ -1508,12 +1509,7 @@ class BrowserTabViewModel @Inject constructor(
15081509
is ShouldLaunchDuckChatLink -> {
15091510
runCatching {
15101511
logcat { "Duck.ai: ShouldLaunchDuckChatLink $urlToNavigate" }
1511-
val queryParameter = urlToNavigate.toUri().getQueryParameter(QUERY)
1512-
if (queryParameter != null) {
1513-
duckChat.openDuckChatWithPrefill(queryParameter)
1514-
} else {
1515-
duckChat.openDuckChat()
1516-
}
1512+
openDuckChatForUrl(urlToNavigate.toUri())
15171513
return
15181514
}
15191515
}
@@ -3802,6 +3798,26 @@ class BrowserTabViewModel @Inject constructor(
38023798
command.value = OpenInNewTab(uri.toString(), tabId)
38033799
}
38043800

3801+
override fun handleDuckChatUrlInCustomTab(uri: Uri): Boolean {
3802+
if (!isCustomTabScreen) return false
3803+
if (!androidBrowserConfig.redirectDuckAiLinksFromCustomTab().isEnabled()) return false
3804+
3805+
openDuckChatForUrl(uri)
3806+
// Finish only the custom tab activity (not the whole task) so we don't tear down the Duck Chat
3807+
// host activity, which the singleTask BrowserActivity may launch into the same task.
3808+
command.value = FinishCustomTab
3809+
return true
3810+
}
3811+
3812+
private fun openDuckChatForUrl(uri: Uri) {
3813+
val queryParameter = uri.getQueryParameter(QUERY)
3814+
if (queryParameter != null) {
3815+
duckChat.openDuckChatWithPrefill(queryParameter)
3816+
} else {
3817+
duckChat.openDuckChat()
3818+
}
3819+
}
3820+
38053821
override fun recoverFromRenderProcessGone() {
38063822
webNavigationState?.let {
38073823
navigationStateChanged(EmptyNavigationState(it))

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,11 @@ class BrowserWebViewClient @Inject constructor(
234234
return false
235235
}
236236

237+
// Redirect duck.ai links out of a custom tab into the Duck Chat experience instead of loading them in the custom tab.
238+
if (isForMainFrame && duckChat.isDuckChatUrl(url) && webViewClientListener?.handleDuckChatUrlInCustomTab(url) == true) {
239+
return true
240+
}
241+
237242
return when (val urlType = specialUrlDetector.determineType(initiatingUrl = webView.originalUrl, uri = url)) {
238243
is SpecialUrlDetector.UrlType.ShouldLaunchSubscriptionLink -> {
239244
subscriptions.launchSubscription(webView.context, url)

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ interface WebViewClientListener {
9393

9494
fun openLinkInNewTab(uri: Uri)
9595

96+
/**
97+
* Called for a main-frame duck.ai navigation. When the page is shown inside a custom tab, the
98+
* implementation opens the Duck Chat experience, closes the custom tab, and returns `true` so
99+
* the navigation is not loaded in the custom tab. Returns `false` otherwise (let navigation proceed).
100+
*/
101+
fun handleDuckChatUrlInCustomTab(uri: Uri): Boolean
102+
96103
fun recoverFromRenderProcessGone()
97104

98105
fun requiresAuthentication(request: BasicAuthenticationRequest)

app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,13 @@ sealed class Command {
481481

482482
data object CloseCustomTab : Command()
483483

484+
/**
485+
* Finishes only the [CustomTabActivity] (not the whole task). Used when the custom tab is being
486+
* dismissed while another activity (e.g. the Duck Chat host) is being launched into the same task,
487+
* where [CloseCustomTab]'s `finishAndRemoveTask()` would tear down that activity too.
488+
*/
489+
data object FinishCustomTab : Command()
490+
484491
data class LaunchPopupMenu(val anchorToNavigationBar: Boolean) : Command()
485492

486493
data class ShowAutoconsentAnimation(

app/src/main/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModel.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.duckduckgo.app.dispatchers
1818

1919
import android.content.Intent
2020
import androidx.browser.customtabs.CustomTabsIntent
21+
import androidx.core.net.toUri
2122
import androidx.lifecycle.ViewModel
2223
import androidx.lifecycle.viewModelScope
2324
import com.duckduckgo.adblocking.api.duckplayer.DuckPlayerSettingsNoParams
@@ -30,6 +31,7 @@ import com.duckduckgo.autofill.api.emailprotection.EmailProtectionLinkVerifier
3031
import com.duckduckgo.common.utils.DispatcherProvider
3132
import com.duckduckgo.customtabs.api.CustomTabDetector
3233
import com.duckduckgo.di.scopes.ActivityScope
34+
import com.duckduckgo.duckchat.api.DuckChat
3335
import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams
3436
import com.duckduckgo.sync.api.setup.SyncUrlIdentifier
3537
import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,6 +48,7 @@ class IntentDispatcherViewModel @Inject constructor(
4648
private val emailProtectionLinkVerifier: EmailProtectionLinkVerifier,
4749
private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector,
4850
private val syncUrlIdentifier: SyncUrlIdentifier,
51+
private val duckChat: DuckChat,
4952
private val appBuildConfig: AppBuildConfig,
5053
) : ViewModel() {
5154

@@ -83,9 +86,9 @@ class IntentDispatcherViewModel @Inject constructor(
8386

8487
val isEmailProtectionLink = emailProtectionLinkVerifier.shouldDelegateToInContextView(intentText, true)
8588
val isDuckDuckGoUrl = intentText?.let { duckDuckGoUrlDetector.isDuckDuckGoUrl(it) } ?: false
86-
89+
val isDuckAiUrl = intentText?.let { duckChat.isDuckChatUrl(it.toUri()) } ?: false
8790
val isSyncPairingUrl = syncUrlIdentifier.shouldDelegateToSyncSetup(intentText)
88-
val customTabRequested = hasSession && !isEmailProtectionLink && !isDuckDuckGoUrl && !isSyncPairingUrl
91+
val customTabRequested = hasSession && !isEmailProtectionLink && !isDuckDuckGoUrl && !isSyncPairingUrl && !isDuckAiUrl
8992

9093
logcat { "Intent $intent received. Has extra session=$hasSession. Intent text=$intentText. Toolbar color=$toolbarColor" }
9194

app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,14 @@ interface AndroidBrowserConfigFeature {
375375
*/
376376
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
377377
fun pdfViewer(): Toggle
378+
379+
/**
380+
* Controls whether duck.ai links tapped inside a custom tab are redirected out of the custom
381+
* tab into the Duck Chat experience (closing the custom tab) instead of loading in the custom tab.
382+
* @return `true` when the remote config has the global "redirectDuckAiLinksFromCustomTab" androidBrowserConfig
383+
* sub-feature flag enabled
384+
* If the remote feature is not present defaults to `true`
385+
*/
386+
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
387+
fun redirectDuckAiLinksFromCustomTab(): Toggle
378388
}

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5567,6 +5567,54 @@ class BrowserTabViewModelTest {
55675567
assertEquals("about:blank", command.title)
55685568
}
55695569

5570+
@Test
5571+
fun whenHandleDuckChatUrlInCustomTabWithQueryAndFeatureEnabledThenOpenDuckChatWithPrefillAndCloseCustomTabAndReturnTrue() = runTest {
5572+
fakeAndroidConfigBrowserFeature.redirectDuckAiLinksFromCustomTab().setRawStoredState(State(enable = true))
5573+
testee.setIsCustomTab(true)
5574+
5575+
val handled = testee.handleDuckChatUrlInCustomTab("https://duck.ai/?q=hello".toUri())
5576+
5577+
assertTrue(handled)
5578+
verify(mockDuckChat).openDuckChatWithPrefill("hello")
5579+
assertTrue(captureCommands().allValues.contains(Command.FinishCustomTab))
5580+
}
5581+
5582+
@Test
5583+
fun whenHandleDuckChatUrlInCustomTabWithoutQueryAndFeatureEnabledThenOpenDuckChatAndCloseCustomTabAndReturnTrue() = runTest {
5584+
fakeAndroidConfigBrowserFeature.redirectDuckAiLinksFromCustomTab().setRawStoredState(State(enable = true))
5585+
testee.setIsCustomTab(true)
5586+
5587+
val handled = testee.handleDuckChatUrlInCustomTab("https://duck.ai/".toUri())
5588+
5589+
assertTrue(handled)
5590+
verify(mockDuckChat).openDuckChat()
5591+
assertTrue(captureCommands().allValues.contains(Command.FinishCustomTab))
5592+
}
5593+
5594+
@Test
5595+
fun whenHandleDuckChatUrlInCustomTabButNotCustomTabThenReturnFalseAndDoNotOpenDuckChat() = runTest {
5596+
fakeAndroidConfigBrowserFeature.redirectDuckAiLinksFromCustomTab().setRawStoredState(State(enable = true))
5597+
testee.setIsCustomTab(false)
5598+
5599+
val handled = testee.handleDuckChatUrlInCustomTab("https://duck.ai/?q=hello".toUri())
5600+
5601+
assertFalse(handled)
5602+
verify(mockDuckChat, never()).openDuckChat()
5603+
verify(mockDuckChat, never()).openDuckChatWithPrefill(any())
5604+
}
5605+
5606+
@Test
5607+
fun whenHandleDuckChatUrlInCustomTabButFeatureDisabledThenReturnFalseAndDoNotOpenDuckChat() = runTest {
5608+
fakeAndroidConfigBrowserFeature.redirectDuckAiLinksFromCustomTab().setRawStoredState(State(enable = false))
5609+
testee.setIsCustomTab(true)
5610+
5611+
val handled = testee.handleDuckChatUrlInCustomTab("https://duck.ai/?q=hello".toUri())
5612+
5613+
assertFalse(handled)
5614+
verify(mockDuckChat, never()).openDuckChat()
5615+
verify(mockDuckChat, never()).openDuckChatWithPrefill(any())
5616+
}
5617+
55705618
@Test
55715619
fun whenHandleNewTabIfEmptyUrlAndNonNoNavigationStateThenThenDoNotSetOmnibarText() {
55725620
loadUrl("url")

app/src/test/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,50 @@ class BrowserWebViewClientTest {
565565
verifyNoInteractions(mockDuckChat)
566566
}
567567

568+
@Test
569+
fun whenMainFrameDuckChatUrlAndListenerHandlesInCustomTabThenReturnTrueAndDoNotClassify() {
570+
val url = "https://duck.ai/?q=example".toUri()
571+
whenever(webResourceRequest.isForMainFrame).thenReturn(true)
572+
whenever(webResourceRequest.url).thenReturn(url)
573+
whenever(mockDuckChat.isDuckChatUrl(url)).thenReturn(true)
574+
whenever(listener.handleDuckChatUrlInCustomTab(url)).thenReturn(true)
575+
576+
assertTrue(testee.shouldOverrideUrlLoading(webView, webResourceRequest))
577+
578+
verify(listener).handleDuckChatUrlInCustomTab(url)
579+
verifyNoInteractions(specialUrlDetector)
580+
}
581+
582+
@Test
583+
fun whenMainFrameDuckChatUrlButListenerDoesNotHandleThenFallThroughToClassification() {
584+
val url = "https://duck.ai/?q=example".toUri()
585+
whenever(webResourceRequest.isForMainFrame).thenReturn(true)
586+
whenever(webResourceRequest.url).thenReturn(url)
587+
whenever(mockDuckChat.isDuckChatUrl(url)).thenReturn(true)
588+
whenever(listener.handleDuckChatUrlInCustomTab(url)).thenReturn(false)
589+
whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any()))
590+
.thenReturn(SpecialUrlDetector.UrlType.Web(url.toString()))
591+
592+
assertFalse(testee.shouldOverrideUrlLoading(webView, webResourceRequest))
593+
594+
verify(listener).handleDuckChatUrlInCustomTab(url)
595+
verify(specialUrlDetector).determineType(initiatingUrl = any(), uri = any())
596+
}
597+
598+
@Test
599+
fun whenSubFrameDuckChatUrlThenListenerNotConsultedForCustomTabRedirect() {
600+
val url = "https://duck.ai/?q=example".toUri()
601+
whenever(webResourceRequest.isForMainFrame).thenReturn(false)
602+
whenever(webResourceRequest.url).thenReturn(url)
603+
whenever(mockDuckChat.isDuckChatUrl(url)).thenReturn(true)
604+
whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any()))
605+
.thenReturn(SpecialUrlDetector.UrlType.Web(url.toString()))
606+
607+
testee.shouldOverrideUrlLoading(webView, webResourceRequest)
608+
609+
verify(listener, never()).handleDuckChatUrlInCustomTab(any())
610+
}
611+
568612
@Test
569613
fun whenShouldOverrideWithShouldNavigateToDuckPlayerSetOriginToSerpAuto() =
570614
runTest {

app/src/test/java/com/duckduckgo/app/dispatchers/IntentDispatcherViewModelTest.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package com.duckduckgo.app.dispatchers
1919
import android.content.Intent
2020
import android.net.Uri
2121
import androidx.browser.customtabs.CustomTabsIntent
22+
import androidx.core.net.toUri
2223
import androidx.test.ext.junit.runners.AndroidJUnit4
2324
import app.cash.turbine.test
2425
import com.duckduckgo.adblocking.api.duckplayer.DuckPlayerSettingsNoParams
@@ -29,6 +30,7 @@ import com.duckduckgo.appbuildconfig.api.BuildFlavor
2930
import com.duckduckgo.autofill.api.emailprotection.EmailProtectionLinkVerifier
3031
import com.duckduckgo.common.test.CoroutineTestRule
3132
import com.duckduckgo.customtabs.api.CustomTabDetector
33+
import com.duckduckgo.duckchat.api.DuckChat
3234
import com.duckduckgo.sync.api.setup.SyncUrlIdentifier
3335
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl
3436
import kotlinx.coroutines.test.runTest
@@ -56,6 +58,7 @@ class IntentDispatcherViewModelTest {
5658
private val emailProtectionLinkVerifier: EmailProtectionLinkVerifier = mock()
5759
private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock()
5860
private val syncUrlIdentifier: SyncUrlIdentifier = mock()
61+
private val duckChat: DuckChat = mock()
5962
private val mockAppBuildConfig: AppBuildConfig = mock()
6063

6164
private lateinit var testee: IntentDispatcherViewModel
@@ -68,6 +71,7 @@ class IntentDispatcherViewModelTest {
6871
emailProtectionLinkVerifier = emailProtectionLinkVerifier,
6972
duckDuckGoUrlDetector = duckDuckGoUrlDetector,
7073
syncUrlIdentifier = syncUrlIdentifier,
74+
duckChat = duckChat,
7175
appBuildConfig = mockAppBuildConfig,
7276
)
7377

@@ -229,6 +233,37 @@ class IntentDispatcherViewModelTest {
229233
}
230234
}
231235

236+
@Test
237+
fun `when Intent received with session and intent text is a duck ai url then custom tab is not requested`() = runTest {
238+
val text = "https://duck.ai/?q=hello"
239+
configureHasSession(true)
240+
whenever(mockIntent.intentText).thenReturn(text)
241+
whenever(duckChat.isDuckChatUrl(text.toUri())).thenReturn(true)
242+
243+
testee.onIntentReceived(mockIntent, isExternal = false)
244+
245+
testee.viewState.test {
246+
val state = awaitItem()
247+
assertFalse(state.customTabRequested)
248+
assertEquals(text, state.intentText)
249+
}
250+
}
251+
252+
@Test
253+
fun `when Intent received with session and intent text is not a duck ai url then custom tab is requested`() = runTest {
254+
val text = "https://example.com"
255+
configureHasSession(true)
256+
whenever(mockIntent.intentText).thenReturn(text)
257+
whenever(duckChat.isDuckChatUrl(text.toUri())).thenReturn(false)
258+
259+
testee.onIntentReceived(mockIntent, isExternal = false)
260+
261+
testee.viewState.test {
262+
val state = awaitItem()
263+
assertTrue(state.customTabRequested)
264+
}
265+
}
266+
232267
@Test
233268
fun whenIntentReceivedForSyncPairingUrlThenCustomTabIsNotRequested() = runTest {
234269
val intentUrl = SyncBarcodeUrl.URL_BASE

0 commit comments

Comments
 (0)