Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import com.duckduckgo.common.ui.view.toPx
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckchat.api.toChatIdOrNull
import com.duckduckgo.duckchat.impl.ui.nativeinput.views.NativeInputWidget
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.subscriptions.api.SubscriptionScreens.SubscriptionPurchase
Expand Down Expand Up @@ -787,8 +788,7 @@ class RealNativeInputManager @Inject constructor(
internal fun extractDuckAiChatId(rawUrl: String?): String? {
if (rawUrl.isNullOrBlank()) return null
val uri = runCatching { rawUrl.toUri() }.getOrNull() ?: return null
if (!duckChat.isDuckChatUrl(uri)) return null
return uri.getQueryParameter("chatID")?.takeIf { it.isNotBlank() }
return uri.toChatIdOrNull(duckChat)
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@

package com.duckduckgo.app.fire

import android.net.Uri
import androidx.core.net.toUri
import com.duckduckgo.app.tabs.model.TabRepository
import com.duckduckgo.dataclearing.api.plugin.ClearableData
import com.duckduckgo.dataclearing.api.plugin.DataClearingPlugin
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckchat.api.toChatIdOrNull
import com.squareup.anvil.annotations.ContributesMultibinding
import logcat.logcat
import javax.inject.Inject
Expand Down Expand Up @@ -61,19 +61,14 @@ class DuckAiTabsCleanupPlugin @Inject constructor(
*/
private suspend fun closeTabsMatching(chatUrls: Set<String>) {
if (chatUrls.isEmpty()) return
val targetChatIds = chatUrls.mapNotNullTo(mutableSetOf()) { it.toUri().chatIdOrNull() }
val targetChatIds = chatUrls.mapNotNullTo(mutableSetOf()) { it.toUri().toChatIdOrNull(duckChat) }
if (targetChatIds.isEmpty()) return
val ids = tabRepository.getTabs()
.filter { tab -> tab.url?.toUri()?.chatIdOrNull() in targetChatIds }
.filter { tab -> tab.url?.toUri()?.toChatIdOrNull(duckChat) in targetChatIds }
.map { it.tabId }
if (ids.isNotEmpty()) {
logcat { "Closing ${ids.size} Duck.ai tab(s) matching the cleared subset" }
tabRepository.deleteTabs(ids)
}
}

private fun Uri.chatIdOrNull(): String? {
if (!duckChat.isDuckChatUrl(this)) return null
return getQueryParameter("chatID")?.takeIf { it.isNotBlank() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 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.duckchat.api

import android.net.Uri

/**
* Returns the chatID from this [Uri] when it is a Duck.ai chat URL with a non-blank chatID;
* null otherwise (including for non-Duck.ai URLs).
*/
fun Uri.toChatIdOrNull(duckChat: DuckChat): String? {
if (!duckChat.isDuckChatUrl(this)) return null
return getQueryParameter("chatID")?.takeIf { it.isNotBlank() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ package com.duckduckgo.duckchat.impl

object DuckChatConstants {
const val JS_MESSAGING_FEATURE_NAME = "aiChat"
const val CHAT_ID_PARAM = "chatID"
const val DUCK_AI_FEATURE_PAGE = "duckai"

object JsResponseKeys {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,7 @@ class RealDuckChat @Inject constructor(
}

override fun buildChatUrl(chatId: String): String {
return appendParameters(mapOf(CHAT_ID_QUERY_NAME to chatId), getDuckChatLink())
return appendParameters(mapOf(CHAT_ID_QUERY_NAME to chatId) + nativeInputParameters(), getDuckChatLink())
}

override suspend fun setDefaultTogglePosition(position: DefaultTogglePosition) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import com.duckduckgo.dataclearing.api.plugin.ClearableData
import com.duckduckgo.dataclearing.api.plugin.DataClearingPlugin
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckchat.impl.DuckChatConstants.CHAT_ID_PARAM
import com.duckduckgo.duckchat.api.toChatIdOrNull
import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository
import com.duckduckgo.duckchat.impl.sync.DuckChatSyncRepository
import com.duckduckgo.sync.api.engine.SyncEngine
Expand Down Expand Up @@ -75,7 +75,7 @@ class DuckChatDataClearingPlugin @Inject constructor(
if (chatUrls.isEmpty()) return
var anyDeleted = false
chatUrls.forEach { chatUrl ->
val chatId = extractChatId(chatUrl) ?: return@forEach
val chatId = chatUrl.toUri().toChatIdOrNull(duckChat) ?: return@forEach
if (duckChatDeleter.deleteChat(chatId)) {
duckChatSyncRepository.recordSingleChatDeletion(chatId)
anyDeleted = true
Expand All @@ -84,10 +84,4 @@ class DuckChatDataClearingPlugin @Inject constructor(
// One sync trigger per user-visible delete action, not N.
if (anyDeleted) syncEngine.triggerSync(SyncEngine.SyncTrigger.DATA_CHANGE)
}

private fun extractChatId(url: String): String? {
val uri = url.toUri()
if (!duckChat.isDuckChatUrl(uri)) return null
return uri.getQueryParameter(CHAT_ID_PARAM)?.takeIf { it.isNotBlank() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckchat.impl.DuckChatConstants.CHAT_ID_PARAM
import com.duckduckgo.duckchat.api.toChatIdOrNull
import com.duckduckgo.duckchat.impl.DuckChatInternal
import com.duckduckgo.duckchat.impl.feature.DuckChatFeature
import com.duckduckgo.duckchat.impl.helper.DuckChatJSHelper
Expand Down Expand Up @@ -666,8 +666,10 @@ class DuckChatContextualViewModel @Inject constructor(

private fun hasChatId(url: String?): Boolean = !extractChatId(url).isNullOrBlank()

private fun extractChatId(url: String?): String? =
url?.toUri()?.getQueryParameter(CHAT_ID_PARAM)?.takeIf { it.isNotBlank() }
private fun extractChatId(url: String?): String? {
val uri = url?.toUri() ?: return null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this gates on isDuckChatUrl via toChatIdOrNull where it didn't before — the test fake had to be relaxed to recognise duck.ai/duckduckgo.com hosts. probably fine since the sheet always loads duck.ai urls by construction, but worth flagging in case there's an edge case (redirects, malformed urls) where we'd want the old leniency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's true, but even though there is no check before in this function, the URL is initialized by the DuckChat.getDuckChatUrl function, so we won't break anything. We are just being more restrictive.

return uri.toChatIdOrNull(duckChat)
}

// Owns the coupled (fullModeUrl, _chatId) invariant: _chatId is always extractChatId(fullModeUrl).
private fun setSheetUrl(url: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.SingleLiveEvent
import com.duckduckgo.common.utils.extensions.toBinaryString
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.impl.DuckChatConstants.CHAT_ID_PARAM
import com.duckduckgo.duckchat.impl.DuckChatInternal
import com.duckduckgo.duckchat.impl.feature.DuckAiChatHistoryFeature
import com.duckduckgo.duckchat.impl.feature.DuckChatFeature
Expand Down Expand Up @@ -863,12 +862,7 @@ class InputScreenViewModel @AssistedInject constructor(
saveLastUsedTogglePosition()
duckChatJSHelper.clearTabContextPromptEvent()
viewModelScope.launch {
val url = duckChat.getDuckChatUrl("", false)
.toUri()
.buildUpon()
.appendQueryParameter(CHAT_ID_PARAM, chatId)
.build()
.toString()
val url = duckChat.buildChatUrl(chatId)
Comment thread
cursor[bot] marked this conversation as resolved.
command.value = Command.SubmitSearch(url)

if (pinned) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

package com.duckduckgo.duckchat.impl.ui

import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.duckduckgo.anvil.annotations.ContributesViewModel
Expand All @@ -40,7 +39,6 @@ import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStateProvider
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStatePublisher
import com.duckduckgo.duckchat.impl.ChatState
import com.duckduckgo.duckchat.impl.DuckChatConstants.CHAT_ID_PARAM
import com.duckduckgo.duckchat.impl.DuckChatInternal
import com.duckduckgo.duckchat.impl.feature.DuckAiChatHistoryFeature
import com.duckduckgo.duckchat.impl.feature.maxUrlSuggestions
Expand Down Expand Up @@ -396,12 +394,7 @@ class NativeInputModeWidgetViewModel @Inject constructor(
}

fun buildChatSuggestionUrl(suggestion: ChatSuggestion): String =
duckChatInternal.getDuckChatUrl("", false)
.toUri()
.buildUpon()
.appendQueryParameter(CHAT_ID_PARAM, suggestion.chatId)
.build()
.toString()
duckChatInternal.buildChatUrl(suggestion.chatId)

private fun getInputMode(
isEnabled: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,28 @@ class RealDuckChatTest {
assertTrue(url == "https://duck.ai/chat?native-input=true&duckai=5")
}

@Test
fun `when native input disabled and build chat url then url contains chatID without native input param`() = runTest {
whenever(mockDuckChatFeatureRepository.isNativeInputFieldUserSettingEnabled()).thenReturn(false)
testee.onPrivacyConfigDownloaded()
coroutineRule.testScope.advanceUntilIdle()

val url = testee.buildChatUrl(chatId = "abc-123")

assertTrue(url == "https://duck.ai/chat?chatID=abc-123&duckai=5")
}

@Test
fun `when native input enabled and build chat url then url contains both chatID and native input param`() = runTest {
whenever(mockDuckChatFeatureRepository.isNativeInputFieldUserSettingEnabled()).thenReturn(true)
testee.onPrivacyConfigDownloaded()
coroutineRule.testScope.advanceUntilIdle()

val url = testee.buildChatUrl(chatId = "abc-123")

assertTrue(url == "https://duck.ai/chat?chatID=abc-123&native-input=true&duckai=5")
}

@Test
fun `when url can be handled by webview return true`() {
assertTrue(testee.canHandleOnAiWebView("https://duck.ai/somepath"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ class DuckChatDataClearingPluginTest {

@Test
fun `DuckChats Selected deletes each chat in the set and records per-chat sync deletions`() = runTest {
whenever(duckChat.isDuckChatUrl(any())).thenReturn(true)
whenever(duckChat.isDuckChatUrl(eq(Uri.parse("https://duck.ai?chatID=alpha")))).thenReturn(true)
whenever(duckChat.isDuckChatUrl(eq(Uri.parse("https://duck.ai?chatID=beta")))).thenReturn(true)
whenever(duckChatDeleter.deleteChat("alpha")).thenReturn(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1577,7 +1577,8 @@ class DuckChatContextualViewModelTest {
sidebar: Boolean,
): String = nextUrl

override fun isDuckChatUrl(uri: android.net.Uri): Boolean = false
override fun isDuckChatUrl(uri: android.net.Uri): Boolean =
uri.host == "duck.ai" || uri.host == "duckduckgo.com"
override suspend fun wasOpenedBefore(): Boolean = false
override fun showNewAddressBarOptionChoiceScreen(
context: android.content.Context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,8 +409,9 @@ class NativeInputModeWidgetViewModelTest {
}

@Test
fun whenBuildChatSuggestionUrlThenAppendsChatIdParam() {
whenever(duckChatInternal.getDuckChatUrl("", false)).thenReturn("https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5")
fun whenBuildChatSuggestionUrlThenDelegatesToDuckChat() {
whenever(duckChatInternal.buildChatUrl("abc-123"))
.thenReturn("https://duckduckgo.com/?ia=chat&duckai=5&chatID=abc-123")
val suggestion = ChatSuggestion(
chatId = "abc-123",
title = "Title",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ class InputScreenViewModelTest {
)
whenever(duckChat.wasOpenedBefore()).thenReturn(false)
whenever(duckChat.getDuckChatUrl(any(), any(), any())).thenReturn(duckChatURL)
whenever(duckChat.buildChatUrl(any())).thenAnswer { "$duckChatURL&chatID=${it.arguments[0]}" }
whenever(duckChat.observeChatSuggestionsUserSettingEnabled()).thenReturn(flowOf(true))
whenever(chatSuggestionsReader.fetchSuggestions(any())).thenReturn(emptyList())
whenever(inputScreenConfigResolver.useTopBar()).thenReturn(true)
Expand Down
Loading