From 0a58125c77c62fedf51f00f02f11597bd534b07d Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 08:52:52 +0100 Subject: [PATCH 01/73] Add feature toggle --- .../duckduckgo/duckchat/impl/feature/DuckChatFeature.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt index 9cf45b496f20..0d94835df7e6 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt @@ -323,4 +323,11 @@ interface DuckChatFeature { */ @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun contextualEntryDismissOnTabChange(): Toggle + + /** + * @return `true` when the "Ask Duck.ai" text selection menu item is enabled. + * If the remote feature is not present defaults to `INTERNAL`. + */ + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun askDuckAi(): Toggle } From d8b31cf8c7f910b9d280791a69b41000dfa2d90f Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 08:59:22 +0100 Subject: [PATCH 02/73] Add showAskDuckAi to feature state --- .../java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt | 5 +++++ .../main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt index 82ec2ffb2221..f846b92ae762 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt @@ -70,6 +70,11 @@ interface DuckAiFeatureState { */ val showContextualMode: StateFlow + /** + * Indicates whether the "Ask Duck.ai" text selection menu item should be shown. + */ + val showAskDuckAi: StateFlow + /** * Indicates whether Duck.ai should be used as digital assistant */ 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 4a46c09dd008..58ba74cbc671 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 @@ -508,6 +508,7 @@ class RealDuckChat @Inject constructor( private val _showVoiceSearchToggle = MutableStateFlow(false) private val _showVoiceChatEntry = MutableStateFlow(false) private val _showContextualMode = MutableStateFlow(false) + private val _showAskDuckAi = MutableStateFlow(false) private val _allowDuckAiAsDigitalAssistant = MutableStateFlow(false) private val _displayedMode = MutableStateFlow(InputMode.SEARCH) private val _inputQuery = MutableStateFlow("") @@ -726,6 +727,8 @@ class RealDuckChat @Inject constructor( override val showContextualMode: StateFlow = _showContextualMode.asStateFlow() + override val showAskDuckAi: StateFlow = _showAskDuckAi.asStateFlow() + override val allowDuckAiAsDigitalAssistant: StateFlow = _allowDuckAiAsDigitalAssistant.asStateFlow() override val nativeInputFieldEnabled: StateFlow = _nativeInputFieldEnabled.asStateFlow() @@ -1155,6 +1158,8 @@ class RealDuckChat @Inject constructor( contextualMenuAllChatsEnabled = contextualSheetRedesignEnabled && duckChatFeature.contextualMenuAllChats().isEnabled() + _showAskDuckAi.emit(contextualSheetRedesignEnabled && duckChatFeature.askDuckAi().isEnabled()) + isAutomaticContextAttachmentEnabled = isContextualModeEnabled && duckChatFeature.automaticContextAttachment() .isEnabled() && duckChatFeatureRepository.isAutomaticPageContextAttachmentUserSettingEnabled() From 5d01d1808a8e193d24562730f3a40a5a79f11e6e Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 09:08:51 +0100 Subject: [PATCH 03/73] Rename showAskDuckAi to showDuckAiTextSelectionAction --- .../java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt | 2 +- .../main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt index f846b92ae762..f03c14efc1a9 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt @@ -73,7 +73,7 @@ interface DuckAiFeatureState { /** * Indicates whether the "Ask Duck.ai" text selection menu item should be shown. */ - val showAskDuckAi: StateFlow + val showDuckAiTextSelectionAction: StateFlow /** * Indicates whether Duck.ai should be used as digital assistant 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 58ba74cbc671..ec95147f386c 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 @@ -508,7 +508,7 @@ class RealDuckChat @Inject constructor( private val _showVoiceSearchToggle = MutableStateFlow(false) private val _showVoiceChatEntry = MutableStateFlow(false) private val _showContextualMode = MutableStateFlow(false) - private val _showAskDuckAi = MutableStateFlow(false) + private val _showDuckAiTextSelectionAction = MutableStateFlow(false) private val _allowDuckAiAsDigitalAssistant = MutableStateFlow(false) private val _displayedMode = MutableStateFlow(InputMode.SEARCH) private val _inputQuery = MutableStateFlow("") @@ -727,7 +727,7 @@ class RealDuckChat @Inject constructor( override val showContextualMode: StateFlow = _showContextualMode.asStateFlow() - override val showAskDuckAi: StateFlow = _showAskDuckAi.asStateFlow() + override val showDuckAiTextSelectionAction: StateFlow = _showDuckAiTextSelectionAction.asStateFlow() override val allowDuckAiAsDigitalAssistant: StateFlow = _allowDuckAiAsDigitalAssistant.asStateFlow() @@ -1158,7 +1158,7 @@ class RealDuckChat @Inject constructor( contextualMenuAllChatsEnabled = contextualSheetRedesignEnabled && duckChatFeature.contextualMenuAllChats().isEnabled() - _showAskDuckAi.emit(contextualSheetRedesignEnabled && duckChatFeature.askDuckAi().isEnabled()) + _showDuckAiTextSelectionAction.emit(contextualSheetRedesignEnabled && duckChatFeature.askDuckAi().isEnabled()) isAutomaticContextAttachmentEnabled = isContextualModeEnabled && duckChatFeature.automaticContextAttachment() From 43d202e50c1e81d8f384eded36cedcd9126c20a8 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 18:29:58 +0100 Subject: [PATCH 04/73] Rename feature --- .../com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt index 0d94835df7e6..c3300f53969b 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt @@ -329,5 +329,5 @@ interface DuckChatFeature { * If the remote feature is not present defaults to `INTERNAL`. */ @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) - fun askDuckAi(): Toggle + fun duckAiTextSelectionAction(): Toggle } From 7510bba785577150cd950a167903f38f55de02c1 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 18:31:13 +0100 Subject: [PATCH 05/73] Rename showDuckAiTextSelectionAction to showTextSelectionAction --- .../java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt | 2 +- .../main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt index f03c14efc1a9..10c7b5b4c30b 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiFeatureState.kt @@ -73,7 +73,7 @@ interface DuckAiFeatureState { /** * Indicates whether the "Ask Duck.ai" text selection menu item should be shown. */ - val showDuckAiTextSelectionAction: StateFlow + val showTextSelectionAction: StateFlow /** * Indicates whether Duck.ai should be used as digital assistant 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 ec95147f386c..a027089c412c 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 @@ -508,7 +508,7 @@ class RealDuckChat @Inject constructor( private val _showVoiceSearchToggle = MutableStateFlow(false) private val _showVoiceChatEntry = MutableStateFlow(false) private val _showContextualMode = MutableStateFlow(false) - private val _showDuckAiTextSelectionAction = MutableStateFlow(false) + private val _showTextSelectionAction = MutableStateFlow(false) private val _allowDuckAiAsDigitalAssistant = MutableStateFlow(false) private val _displayedMode = MutableStateFlow(InputMode.SEARCH) private val _inputQuery = MutableStateFlow("") @@ -727,7 +727,7 @@ class RealDuckChat @Inject constructor( override val showContextualMode: StateFlow = _showContextualMode.asStateFlow() - override val showDuckAiTextSelectionAction: StateFlow = _showDuckAiTextSelectionAction.asStateFlow() + override val showTextSelectionAction: StateFlow = _showTextSelectionAction.asStateFlow() override val allowDuckAiAsDigitalAssistant: StateFlow = _allowDuckAiAsDigitalAssistant.asStateFlow() @@ -1158,7 +1158,7 @@ class RealDuckChat @Inject constructor( contextualMenuAllChatsEnabled = contextualSheetRedesignEnabled && duckChatFeature.contextualMenuAllChats().isEnabled() - _showDuckAiTextSelectionAction.emit(contextualSheetRedesignEnabled && duckChatFeature.askDuckAi().isEnabled()) + _showTextSelectionAction.emit(contextualSheetRedesignEnabled && duckChatFeature.duckAiTextSelectionAction().isEnabled()) isAutomaticContextAttachmentEnabled = isContextualModeEnabled && duckChatFeature.automaticContextAttachment() From d78087916fea9bc715b8274112d5d416f0baa29f Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 19:09:05 +0100 Subject: [PATCH 06/73] Add text selection menu action copy --- app/src/main/res/values/donottranslate.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 3be6e6d85b8a..8bb93b790202 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -136,4 +136,6 @@ Delete %1$d chats? + + Ask Duck.ai From a6d5dfa860f87fc0243aa2d0fec7764dbaa1cd45 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 20:00:40 +0100 Subject: [PATCH 07/73] Add text selection attachment --- .../attachment/TextSelectionAttachment.kt | 22 +++++++ .../TextSelectionAttachmentsContainerView.kt | 57 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/attachment/TextSelectionAttachment.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/attachment/TextSelectionAttachment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/attachment/TextSelectionAttachment.kt new file mode 100644 index 000000000000..3b57fdba4b77 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/attachment/TextSelectionAttachment.kt @@ -0,0 +1,22 @@ +/* + * 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.impl.ui.nativeinput.attachment + +data class TextSelectionAttachment( + val id: String, + val text: String, +) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt new file mode 100644 index 000000000000..d034c8204b04 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt @@ -0,0 +1,57 @@ +/* + * 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.impl.ui.nativeinput.views + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.ImageView +import android.widget.LinearLayout +import com.duckduckgo.common.ui.view.text.DaxTextView +import com.duckduckgo.duckchat.impl.R +import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.TextSelectionAttachment + +class TextSelectionAttachmentsContainerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + private var attachments: List = emptyList() + var onAttachmentRemoved: ((String) -> Unit)? = null + + init { + orientation = HORIZONTAL + clipChildren = false + clipToPadding = false + } + + fun current(): List = attachments + + fun render(attachments: List) { + this.attachments = attachments + removeAllViews() + attachments.forEach { attachment -> + val itemView = LayoutInflater.from(context).inflate(R.layout.view_page_context_attachment_item, this, false) + itemView.findViewById(R.id.pageContextTitle).text = attachment.text + itemView.findViewById(R.id.pageContextRemove).setOnClickListener { + onAttachmentRemoved?.invoke(attachment.id) + } + addView(itemView) + } + } +} From b31e3c20a838999fafde8d3da9720d9866239a96 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 20:04:56 +0100 Subject: [PATCH 08/73] Move strings to duck-chat module --- app/src/main/res/values/donottranslate.xml | 2 -- .../src/main/res/values/donottranslate.xml | 12 ++++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 8bb93b790202..3be6e6d85b8a 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -136,6 +136,4 @@ Delete %1$d chats? - - Ask Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index 2246c8474f1f..407b6aafccae 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -43,4 +43,16 @@ Select Chats Chat Protection + + + All Chats + + + Summarize this selection + Summarize this selection. + Translate this selection + Translate this selection into %1$s. + + + Ask Duck.ai From 5e4ed60632c1672f754abc83b369a9f3fd089112 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 21:18:00 +0100 Subject: [PATCH 09/73] Open contextual via BrowserNav --- .../java/com/duckduckgo/app/browser/BrowserActivity.kt | 8 ++++++++ .../java/com/duckduckgo/app/browser/BrowserTabFragment.kt | 6 ++++++ .../duckduckgo/app/browser/navigation/AppBrowserNav.kt | 7 +++++-- .../src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt | 2 ++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 39e6e6fae23b..7f73e6d43e6c 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -857,6 +857,11 @@ open class BrowserActivity : DuckDuckGoActivity() { return } + if (intent.getBooleanExtra(OPEN_DUCK_CHAT_CONTEXTUAL, false)) { + currentTab?.launchDuckAiContextual() + return + } + if (intent.getBooleanExtra(CLOSE_DUCK_CHAT, false)) { if (currentTab?.isInDuckAiMode() == true) { closeDuckChatFullScreen() @@ -1315,6 +1320,7 @@ open class BrowserActivity : DuckDuckGoActivity() { interstitialScreen: Boolean = false, openExistingTabId: String? = null, openDuckChat: Boolean = false, + openDuckChatContextual: Boolean = false, closeDuckChat: Boolean = false, duckChatUrl: String? = null, duckChatSessionActive: Boolean = false, @@ -1332,6 +1338,7 @@ open class BrowserActivity : DuckDuckGoActivity() { intent.putExtra(LAUNCH_FROM_INTERSTITIAL_EXTRA, interstitialScreen) intent.putExtra(OPEN_EXISTING_TAB_ID_EXTRA, openExistingTabId) intent.putExtra(OPEN_DUCK_CHAT, openDuckChat) + intent.putExtra(OPEN_DUCK_CHAT_CONTEXTUAL, openDuckChatContextual) intent.putExtra(DUCK_CHAT_ENTRY_POINT_EXTRA, launchSource.toDuckChatEntryPoint()?.name) intent.putExtra(CLOSE_DUCK_CHAT, closeDuckChat) intent.putExtra(DUCK_CHAT_URL, duckChatUrl) @@ -1369,6 +1376,7 @@ open class BrowserActivity : DuckDuckGoActivity() { const val LAUNCH_SOURCE_PIXEL_VALUE = "LAUNCH_SOURCE_PIXEL_VALUE" private const val OPEN_DUCK_CHAT = "OPEN_DUCK_CHAT_EXTRA" + private const val OPEN_DUCK_CHAT_CONTEXTUAL = "OPEN_DUCK_CHAT_CONTEXTUAL_EXTRA" private const val DUCK_CHAT_ENTRY_POINT_EXTRA = "DUCK_CHAT_ENTRY_POINT_EXTRA" private const val CLOSE_DUCK_CHAT = "CLOSE_DUCK_CHAT_EXTRA" private const val DUCK_CHAT_URL = "DUCK_CHAT_URL" diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index c4bfcadfaa36..4c1159f9348b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -4027,6 +4027,12 @@ class BrowserTabFragment : ) } + fun launchDuckAiContextual() { + viewLifecycleOwner.lifecycleScope.launch(dispatchers.main()) { + duckChatContextual.launch(tabId, webView?.url, webView) { showDuckChatContextualSheet(tabId) } + } + } + private fun showDuckChatContextualSheet(tabId: String) { binding.duckAiContextualFragmentContainer.show() diff --git a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt index 5f3a787e4524..439f868033e2 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt @@ -20,6 +20,7 @@ import android.content.Context import android.content.Intent import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.mode.InAppNavigation +import com.duckduckgo.app.browser.mode.SelectedTextSearch import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -55,11 +56,13 @@ class AppBrowserNav @Inject constructor() : BrowserNav { hasSessionActive: Boolean, duckChatUrl: String, forceImageGeneration: Boolean, + isContextual: Boolean, ): Intent { return BrowserActivity.intent( context = context, - launchSource = InAppNavigation, - openDuckChat = true, + launchSource = if (isContextual) SelectedTextSearch else InAppNavigation, + openDuckChat = !isContextual, + openDuckChatContextual = isContextual, duckChatUrl = duckChatUrl, duckChatSessionActive = hasSessionActive, duckChatForceImageGeneration = forceImageGeneration, diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt index 7baa90aaa894..e3e69a78a785 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt @@ -37,12 +37,14 @@ interface BrowserNav { * Returns an Intent that opens Duck.ai full screen in a new tab. * * @param forceImageGeneration when true, the new tab's native input preselects the image-generation tool. + * @param isContextual when true, opens the contextual Duck.ai flow instead of a full screen tab. */ fun openDuckChat( context: Context, hasSessionActive: Boolean = false, duckChatUrl: String, forceImageGeneration: Boolean = false, + isContextual: Boolean = false, ): Intent fun closeDuckChat(context: Context): Intent From 9d3824b073635f8fc2258b08e80d581bea4d9cea Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 21:19:06 +0100 Subject: [PATCH 10/73] Update suggestions catalog --- .../duckchat-impl/src/main/assets/PageSuggestionsCatalog.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/assets/PageSuggestionsCatalog.json b/duckchat/duckchat-impl/src/main/assets/PageSuggestionsCatalog.json index eeda79c8cbad..09d9697eb906 100644 --- a/duckchat/duckchat-impl/src/main/assets/PageSuggestionsCatalog.json +++ b/duckchat/duckchat-impl/src/main/assets/PageSuggestionsCatalog.json @@ -6,6 +6,9 @@ "summarize-page": { "label": "Summarize this page", "icon": "summary", "prompt": "Summarize this page." }, "translate-page": { "label": "Translate this page", "icon": "translate", "prompt": "Translate this page into {language}.", "condition": "differentLanguage" }, + "summarize-selection": { "label": "Summarize this selection", "icon": "summary", "prompt": "Summarize this selection." }, + "translate-selection": { "label": "Translate this selection", "icon": "translate", "prompt": "Translate this selection into {language}.", "condition": "differentLanguage" }, + "key-takeaways": { "label": "What are the key takeaways?", "icon": "summary", "prompt": "What are the key takeaways from this article?" }, "explain-simply": { "label": "Explain this simply", "prompt": "Explain this in simple, plain language." }, "counterarguments": { "label": "What are the counterarguments?", "prompt": "What are the main counterarguments or criticisms of this?" }, From 5fb42750c1f58d47b8ddf8810e761b3d8ca05b48 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 21:30:09 +0100 Subject: [PATCH 11/73] Update manifest --- .../duckchat-impl/src/main/AndroidManifest.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/AndroidManifest.xml b/duckchat/duckchat-impl/src/main/AndroidManifest.xml index c6c206c30a3a..5ae5f4c1026e 100644 --- a/duckchat/duckchat-impl/src/main/AndroidManifest.xml +++ b/duckchat/duckchat-impl/src/main/AndroidManifest.xml @@ -46,6 +46,22 @@ android:label="@string/duck_ai_edit_prompt_title" android:windowSoftInputMode="stateVisible|adjustResize" /> + + + + + + + + + Date: Mon, 7 Sep 2026 21:56:54 +0100 Subject: [PATCH 12/73] Add title string --- duckchat/duckchat-impl/src/main/res/values/donottranslate.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index 407b6aafccae..e0878b689d77 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -53,6 +53,9 @@ Translate this selection Translate this selection into %1$s. + + Text selection + Ask Duck.ai From 50451a8db340669c86be31c407360168bb72a0f9 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 22:02:48 +0100 Subject: [PATCH 13/73] Attach selections json param --- .../java/com/duckduckgo/app/browser/BrowserTabFragment.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 4c1159f9348b..bbda78285f3a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -1434,7 +1434,7 @@ class BrowserTabFragment : } }, onSearchSubmitted = { query -> onUserSubmittedText(query) }, - onDuckAiChatSubmitted = { query, modelId, reasoningEffort, selectedTool, imagesJson, filesJson -> + onDuckAiChatSubmitted = { query, modelId, reasoningEffort, selectedTool, imagesJson, filesJson, selectionsJson -> viewModel.onDuckAiChatPromptSubmitted() contentScopeScripts.sendSubscriptionEvent( SubscriptionEventData( @@ -1465,6 +1465,9 @@ class BrowserTabFragment : } }, ) + if (selectionsJson != null) { + put("selections", selectionsJson) + } }, ), ) From 704166d17c693cca76ecf152ebf88d6ad55bf8bb Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 22:09:23 +0100 Subject: [PATCH 14/73] Add selections to contextual --- .../impl/contextual/ContextualEntryPromptStore.kt | 2 ++ .../DuckChatContextualWebViewViewModel.kt | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualEntryPromptStore.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualEntryPromptStore.kt index f4a2c8fdea4b..262af9a8b08d 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualEntryPromptStore.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualEntryPromptStore.kt @@ -19,6 +19,7 @@ package com.duckduckgo.duckchat.impl.contextual import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn +import org.json.JSONArray import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @@ -34,6 +35,7 @@ data class ContextualEntryPrompt( val tabId: String, val prompt: NativeInputPrompt, val serializedPageContext: String?, + val selectionsJson: JSONArray? = null, ) /** diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModel.kt index f550746018e2..d54525710da0 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModel.kt @@ -116,6 +116,9 @@ class DuckChatContextualWebViewViewModel @Inject constructor( // native-input auto-attach that may run before the web app is ready. private var pendingEntryPageContext: String? = null + // The selections the entry dialog had attached at hand-off. + private var pendingEntrySelections: JSONArray? = null + private var hidingSheetForNewChat = false sealed class Command { @@ -309,6 +312,7 @@ class DuckChatContextualWebViewViewModel @Inject constructor( // to or remove from it. pendingEntryPrompt = entry.prompt pendingEntryPageContext = entry.serializedPageContext + pendingEntrySelections = entry.selectionsJson val handedOffWithoutContext = entry.serializedPageContext == null val chatUrl = duckChat.getDuckChatUrl("", false, sidebar = true) withContext(dispatchers.main()) { @@ -334,8 +338,10 @@ class DuckChatContextualWebViewViewModel @Inject constructor( fun onWebAppReady() { val entry = pendingEntryPrompt ?: return val entryPageContext = pendingEntryPageContext + val entrySelections = pendingEntrySelections pendingEntryPrompt = null pendingEntryPageContext = null + pendingEntrySelections = null submitPrompt( prompt = entry.prompt, modelId = entry.modelId, @@ -344,6 +350,7 @@ class DuckChatContextualWebViewViewModel @Inject constructor( imagesJson = entry.imagesJson, filesJson = entry.filesJson, pageContextSerialized = entryPageContext, + selectionsJson = entrySelections, ) } @@ -369,9 +376,11 @@ class DuckChatContextualWebViewViewModel @Inject constructor( imagesJson: JSONArray? = null, filesJson: JSONArray? = null, pageContextSerialized: String?, + selectionsJson: JSONArray? = null, ) { viewModelScope.launch(dispatchers.io()) { - val contextPrompt = generateContextPrompt(prompt, modelId, reasoningEffort, selectedTool, imagesJson, filesJson, pageContextSerialized) + val contextPrompt = + generateContextPrompt(prompt, modelId, reasoningEffort, selectedTool, imagesJson, filesJson, pageContextSerialized, selectionsJson) val prefillText = followUpPrefill?.takeIf { it.isNotEmpty() } val prefillEvent = prefillText?.let { generatePrefillEvent(it) } withContext(dispatchers.main()) { @@ -459,6 +468,7 @@ class DuckChatContextualWebViewViewModel @Inject constructor( imagesJson: JSONArray? = null, filesJson: JSONArray? = null, pageContextSerialized: String?, + selectionsJson: JSONArray? = null, ): SubscriptionEventData { val pageContext = pageContextSerialized @@ -502,6 +512,7 @@ class DuckChatContextualWebViewViewModel @Inject constructor( }, ) pageContext?.let { put("pageContext", it) } + selectionsJson?.let { put("selections", it) } } return SubscriptionEventData( From f3fe6dbc866e31dd64f81affb22fa2a5341f7183 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 22:14:18 +0100 Subject: [PATCH 15/73] Resolve text selection suggestions --- .../ContextualSuggestedPromptsProvider.kt | 14 ++++++++++++++ .../suggestions/ContextualSuggestionsMatcher.kt | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt index cc59b53d6c5c..5fcafa17def2 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt @@ -33,6 +33,9 @@ interface ContextualSuggestedPromptsProvider { suspend fun resolveSuggestions(input: ResolvePageSuggestionsInput): ResolvedPageSuggestions suspend fun maxSuggestedPrompts(): Int suspend fun prioritySuggestionIds(): Set + + /** The prompts offered for a single attached text selection, rather than for the page as a whole. */ + suspend fun resolveTextSelectionSuggestions(input: ResolvePageSuggestionsInput): List } @ContributesBinding(AppScope::class) @@ -81,6 +84,14 @@ class RealContextualSuggestedPromptsProvider @Inject constructor( ) } + override suspend fun resolveTextSelectionSuggestions( + input: ResolvePageSuggestionsInput, + ): List = withContext(dispatcherProvider.io()) { + val catalog = bundledCatalog ?: return@withContext emptyList() + ContextualSuggestionsMatcher.resolveIds(TEXT_SELECTION_SUGGESTION_IDS, input, catalog) + .map { localize(it, input) } + } + override suspend fun maxSuggestedPrompts(): Int = withContext(dispatcherProvider.io()) { bundledCatalog?.maxSuggestedPrompts ?: 1 } @@ -102,9 +113,12 @@ class RealContextualSuggestedPromptsProvider @Inject constructor( companion object { private const val CATALOG_ASSET_PATH = "PageSuggestionsCatalog.json" private const val SUGGESTION_ID_SUMMARIZE_PAGE = "summarize-page" + private val TEXT_SELECTION_SUGGESTION_IDS = listOf("summarize-selection", "translate-selection") private val LOCALIZED_COPY_RES = mapOf( "translate-page" to (R.string.duckAiSuggestionTranslatePageLabel to R.string.duckAiSuggestionTranslatePagePrompt), + "summarize-selection" to (R.string.duckAiSuggestionSummarizeSelectionLabel to R.string.duckAiSuggestionSummarizeSelectionPrompt), + "translate-selection" to (R.string.duckAiSuggestionTranslateSelectionLabel to R.string.duckAiSuggestionTranslateSelectionPrompt), "key-takeaways" to (R.string.duckAiSuggestionKeyTakeawaysLabel to R.string.duckAiSuggestionKeyTakeawaysPrompt), "explain-simply" to (R.string.duckAiSuggestionExplainSimplyLabel to R.string.duckAiSuggestionExplainSimplyPrompt), "counterarguments" to (R.string.duckAiSuggestionCounterargumentsLabel to R.string.duckAiSuggestionCounterargumentsPrompt), diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsMatcher.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsMatcher.kt index 98f0e6d9c02a..ef0a33db048f 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsMatcher.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsMatcher.kt @@ -108,6 +108,21 @@ object ContextualSuggestionsMatcher { ) } + fun resolveIds( + ids: List, + input: ResolvePageSuggestionsInput, + catalog: SuggestionCatalog, + ): List = ids.mapNotNull { id -> + val entry = catalog.catalog[id] ?: return@mapNotNull null + if (!conditionPasses(entry.condition, input)) return@mapNotNull null + ContextualSuggestedPrompt( + id = id, + label = entry.label, + prompt = applyTemplate(entry.prompt, input), + icon = entry.icon, + ) + } + fun classifyPageType(signals: PageTypeSignals?): SuggestionsPageType { if (signals == null) return SuggestionsPageType.NONE for (type in signals.jsonLdType) { From a20b13899beb613af75999c1f16ee14bb7049cd5 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:27:36 +0100 Subject: [PATCH 16/73] Add text selection store --- .../textselection/TextSelectionStore.kt | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt new file mode 100644 index 000000000000..05858c093adc --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt @@ -0,0 +1,73 @@ +/* + * 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.impl.ui.nativeinput.textselection + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +data class TextSelection( + val id: String, + val text: String, +) + +interface TextSelectionStore { + fun selections(tabId: String): StateFlow> + fun add(tabId: String, text: String) + fun consume(tabId: String): List + fun remove(tabId: String, id: String) + + companion object { + const val MAX_SELECTIONS = 5 + } +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealTextSelectionStore @Inject constructor() : TextSelectionStore { + + private val selections = ConcurrentHashMap>>() + + override fun selections(tabId: String): StateFlow> = flowFor(tabId) + + override fun add(tabId: String, text: String) { + val selection = selectionOf(text) ?: return + flowFor(tabId).update { (it + selection).take(TextSelectionStore.MAX_SELECTIONS) } + } + + override fun consume(tabId: String): List = flowFor(tabId).getAndUpdate { emptyList() } + + override fun remove(tabId: String, id: String) { + flowFor(tabId).update { current -> current.filterNot { it.id == id } } + } + + private fun flowFor(tabId: String): MutableStateFlow> = + selections.getOrPut(tabId) { MutableStateFlow(emptyList()) } + + private fun selectionOf(text: String): TextSelection? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return null + return TextSelection(UUID.randomUUID().toString(), trimmed) + } +} From 4c8dc17f5625f591b2780f548ee783463203afbb Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:29:30 +0100 Subject: [PATCH 17/73] Add text selection to openDuckChat --- .../java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt | 2 ++ browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt index 439f868033e2..63f65cea7ac6 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt @@ -57,6 +57,7 @@ class AppBrowserNav @Inject constructor() : BrowserNav { duckChatUrl: String, forceImageGeneration: Boolean, isContextual: Boolean, + textSelection: String?, ): Intent { return BrowserActivity.intent( context = context, @@ -66,6 +67,7 @@ class AppBrowserNav @Inject constructor() : BrowserNav { duckChatUrl = duckChatUrl, duckChatSessionActive = hasSessionActive, duckChatForceImageGeneration = forceImageGeneration, + duckChatTextSelection = textSelection, ) } diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt index e3e69a78a785..d4440652bce5 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt @@ -38,6 +38,7 @@ interface BrowserNav { * * @param forceImageGeneration when true, the new tab's native input preselects the image-generation tool. * @param isContextual when true, opens the contextual Duck.ai flow instead of a full screen tab. + * @param textSelection selected page text to attach to the contextual chat, if any. */ fun openDuckChat( context: Context, @@ -45,6 +46,7 @@ interface BrowserNav { duckChatUrl: String, forceImageGeneration: Boolean = false, isContextual: Boolean = false, + textSelection: String? = null, ): Intent fun closeDuckChat(context: Context): Intent From 474d0a817b543fd5586d4a5a715b6c8f744adefa Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:30:34 +0100 Subject: [PATCH 18/73] Add SelectedTextDuckAiActivity --- .../SelectedTextDuckAiActivity.kt | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt new file mode 100644 index 000000000000..5354b93080b9 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt @@ -0,0 +1,69 @@ +/* + * 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.impl.ui.nativeinput.textselection + +import android.content.Intent +import android.os.Bundle +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.tabs.BrowserNav +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.impl.DuckChatInternal +import logcat.LogPriority +import logcat.logcat +import javax.inject.Inject + +/** + * Exists purely to pull out the intent extra and attach the selection to Duck.ai. + * This needs to be its own Activity so that we can customize the label that is user-facing, presented when the user selects some text. + */ +@InjectWith(ActivityScope::class) +class SelectedTextDuckAiActivity : DuckDuckGoActivity() { + + @Inject + lateinit var browserNav: BrowserNav + + @Inject + lateinit var duckChatInternal: DuckChatInternal + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + extractSelection(intent)?.let { selection -> + startActivity( + browserNav.openDuckChat( + this, + duckChatUrl = duckChatInternal.getDuckChatUrl(query = "", autoPrompt = false), + isContextual = !isExternalSelection(), + textSelection = selection, + ), + ) + } + finish() + } + + private fun extractSelection(intent: Intent?): String? { + if (intent == null) return null + + val textSelection = intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT) + if (!textSelection.isNullOrBlank()) return textSelection + + logcat(LogPriority.WARN) { "SelectedTextDuckAiActivity launched with unexpected intent format" } + return null + } + + private fun isExternalSelection(): Boolean = callingPackage != packageName +} From cbaa67c6e5a5db70e6a95f146129fcf75df99acc Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:37:57 +0100 Subject: [PATCH 19/73] Fix concurrency bug in store --- .../impl/ui/nativeinput/textselection/TextSelectionStore.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt index 05858c093adc..012158957dca 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt @@ -63,7 +63,7 @@ class RealTextSelectionStore @Inject constructor() : TextSelectionStore { } private fun flowFor(tabId: String): MutableStateFlow> = - selections.getOrPut(tabId) { MutableStateFlow(emptyList()) } + selections.computeIfAbsent(tabId) { MutableStateFlow(emptyList()) } private fun selectionOf(text: String): TextSelection? { val trimmed = text.trim() From bf295cf8fcc48ce163d25070776ade3a4be38dd1 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:42:42 +0100 Subject: [PATCH 20/73] Add pending text selection to BrowserActivity --- .../java/com/duckduckgo/app/browser/BrowserActivity.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 7f73e6d43e6c..c9c55c17e7b1 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -846,6 +846,7 @@ open class BrowserActivity : DuckDuckGoActivity() { } if (intent.getBooleanExtra(OPEN_DUCK_CHAT, false)) { + pendingDuckChatTextSelection = intent.getStringExtra(DUCK_CHAT_TEXT_SELECTION) val sourceTabId = intent.getStringExtra(SOURCE_TAB_ID_EXTRA) pendingDuckChatForceImageGeneration = intent.getBooleanExtra(DUCK_CHAT_FORCE_IMAGE_GENERATION, false) intent.getStringExtra(DUCK_CHAT_ENTRY_POINT_EXTRA)?.let { source -> @@ -858,7 +859,7 @@ open class BrowserActivity : DuckDuckGoActivity() { } if (intent.getBooleanExtra(OPEN_DUCK_CHAT_CONTEXTUAL, false)) { - currentTab?.launchDuckAiContextual() + currentTab?.launchDuckAiContextual(intent.getStringExtra(DUCK_CHAT_TEXT_SELECTION)) return } @@ -1111,6 +1112,9 @@ open class BrowserActivity : DuckDuckGoActivity() { globalActivityStarter.start(this, DownloadsScreenNoParams) } + private var pendingDuckChatTextSelection: String? = null + fun consumePendingDuckChatTextSelection(): String? = pendingDuckChatTextSelection.also { pendingDuckChatTextSelection = null } + private fun launchDuckAi(url: String?, sourceTabId: String? = null) { isDuckChatVisible = true // The tab to return to when this Duck.ai tab is closed. @@ -1321,6 +1325,7 @@ open class BrowserActivity : DuckDuckGoActivity() { openExistingTabId: String? = null, openDuckChat: Boolean = false, openDuckChatContextual: Boolean = false, + duckChatTextSelection: String? = null, closeDuckChat: Boolean = false, duckChatUrl: String? = null, duckChatSessionActive: Boolean = false, @@ -1339,6 +1344,7 @@ open class BrowserActivity : DuckDuckGoActivity() { intent.putExtra(OPEN_EXISTING_TAB_ID_EXTRA, openExistingTabId) intent.putExtra(OPEN_DUCK_CHAT, openDuckChat) intent.putExtra(OPEN_DUCK_CHAT_CONTEXTUAL, openDuckChatContextual) + intent.putExtra(DUCK_CHAT_TEXT_SELECTION, duckChatTextSelection) intent.putExtra(DUCK_CHAT_ENTRY_POINT_EXTRA, launchSource.toDuckChatEntryPoint()?.name) intent.putExtra(CLOSE_DUCK_CHAT, closeDuckChat) intent.putExtra(DUCK_CHAT_URL, duckChatUrl) @@ -1377,6 +1383,7 @@ open class BrowserActivity : DuckDuckGoActivity() { private const val OPEN_DUCK_CHAT = "OPEN_DUCK_CHAT_EXTRA" private const val OPEN_DUCK_CHAT_CONTEXTUAL = "OPEN_DUCK_CHAT_CONTEXTUAL_EXTRA" + private const val DUCK_CHAT_TEXT_SELECTION = "DUCK_CHAT_TEXT_SELECTION_EXTRA" private const val DUCK_CHAT_ENTRY_POINT_EXTRA = "DUCK_CHAT_ENTRY_POINT_EXTRA" private const val CLOSE_DUCK_CHAT = "CLOSE_DUCK_CHAT_EXTRA" private const val DUCK_CHAT_URL = "DUCK_CHAT_URL" From fc36a7749da10cbf61d047a4dee1a8acfe22e742 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:50:41 +0100 Subject: [PATCH 21/73] Add text selection to BrowserTabFragment --- .../java/com/duckduckgo/app/browser/BrowserActivity.kt | 2 +- .../com/duckduckgo/app/browser/BrowserTabFragment.kt | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index c9c55c17e7b1..7eef1eb0994d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -859,7 +859,7 @@ open class BrowserActivity : DuckDuckGoActivity() { } if (intent.getBooleanExtra(OPEN_DUCK_CHAT_CONTEXTUAL, false)) { - currentTab?.launchDuckAiContextual(intent.getStringExtra(DUCK_CHAT_TEXT_SELECTION)) + currentTab?.launchContextualDuckAi(intent.getStringExtra(DUCK_CHAT_TEXT_SELECTION)) return } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index bbda78285f3a..03b6981e2769 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -1409,7 +1409,7 @@ class BrowserTabFragment : } } - private fun showNativeInput(query: String = "", forceImageGeneration: Boolean = false) { + private fun showNativeInput(query: String = "", forceImageGeneration: Boolean = false, textSelection: String? = null) { nativeInputManager.showNativeInput( tabId = tabId, layoutInflater = layoutInflater, @@ -1419,6 +1419,7 @@ class BrowserTabFragment : query = query, initialInputMode = viewModel.consumeInitialInputMode(), forceImageGeneration = forceImageGeneration, + textSelection = textSelection, callbacks = NativeInputCallbacks( onSearchTextChanged = { text -> onUserEnteredText(text) }, onClearAutocomplete = { @@ -2446,7 +2447,7 @@ class BrowserTabFragment : browserNavigationBarIntegration.configureDuckAIViewMode() val forceImageGeneration = !nativeInputManager.isNativeInputShown() && (browserActivity?.consumeDuckChatForceImageGeneration() ?: false) - showNativeInput(forceImageGeneration = forceImageGeneration) + showNativeInput(forceImageGeneration = forceImageGeneration, textSelection = browserActivity?.consumePendingDuckChatTextSelection()) } private fun showMaliciousWarning( @@ -4030,9 +4031,9 @@ class BrowserTabFragment : ) } - fun launchDuckAiContextual() { + fun launchContextualDuckAi(textSelection: String? = null) { viewLifecycleOwner.lifecycleScope.launch(dispatchers.main()) { - duckChatContextual.launch(tabId, webView?.url, webView) { showDuckChatContextualSheet(tabId) } + duckChatContextual.launch(tabId, webView?.url, webView, textSelection) { showDuckChatContextualSheet(tabId) } } } From e1208b31e3a32e9f523d0755ef09d234f8452252 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:51:45 +0100 Subject: [PATCH 22/73] Add text selections container to attachment view --- .../ui/nativeinput/views/AttachmentView.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index 8872fbddac47..1eb485b5c069 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -46,6 +46,7 @@ import com.duckduckgo.duckchat.impl.nativeinput.NativeInputHost import com.duckduckgo.duckchat.impl.ui.AttachmentViewModel import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.ImageAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.PageContextAttachment +import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.TextSelectionAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedFile import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachment @@ -69,6 +70,7 @@ class AttachmentView( var isEditMode: Boolean = false var onAskAboutPage: (() -> Unit)? = null var onPageContextRemoved: (() -> Unit)? = null + var onTextSelectionRemoved: ((String) -> Unit)? = null private var viewModel: AttachmentViewModel? = null private var faviconManager: FaviconManager? = null @@ -80,6 +82,7 @@ class AttachmentView( private var imageAttachmentsContainer: ImageAttachmentsContainerView? = null private var fileAttachmentsContainer: FileAttachmentsContainerView? = null private var pageContextContainer: PageContextAttachmentView? = null + private var textSelectionsContainer: TextSelectionAttachmentsContainerView? = null private var limitErrorView: TextView? = null init { @@ -138,6 +141,8 @@ class AttachmentView( fun clearAttachmentsForNewChat() = viewModel?.clearAttachmentsForNewChat() + fun setTextSelections(selections: List) = viewModel?.setTextSelections(selections) + fun setPageContext(attachment: PageContextAttachment) = viewModel?.setPageContext(attachment) fun clearPageContext() = viewModel?.removePageContext() @@ -195,6 +200,12 @@ class AttachmentView( row.addView(pageContext) pageContextContainer = pageContext + val selections = TextSelectionAttachmentsContainerView(context).also { + it.onAttachmentRemoved = { id -> onTextSelectionRemoved?.invoke(id) } + } + row.addView(selections) + textSelectionsContainer = selections + val imagesContainer = ImageAttachmentsContainerView(context).also { it.onAttachmentRemoved = { id -> vm.removeImageAttachment(id, isEditMode) } } @@ -223,6 +234,7 @@ class AttachmentView( syncImages(imagesView, state) syncFiles(state) syncPageContext(state) + syncTextSelections(state) val errorMessage = effectiveLimitError( imageLimitError = state.imageLimitError ?: state.fileLimitError @@ -258,6 +270,13 @@ class AttachmentView( } } + private fun syncTextSelections(state: AttachmentViewModel.AttachmentState) { + val view = textSelectionsContainer ?: return + if (view.current() != state.textSelections) { + view.render(state.textSelections) + } + } + private fun syncPageContext(state: AttachmentViewModel.AttachmentState) { val view = pageContextContainer ?: return val next = state.pageContext From acefb173695f27619f8043e2133534e809ea77cc Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:52:37 +0100 Subject: [PATCH 23/73] Update view model --- .../duckchat/impl/ui/AttachmentViewModel.kt | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index 92f0f07a835d..599d0b200733 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -41,6 +41,7 @@ import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.ImageAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.LimitsHandler import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.PageContextAttachment +import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.TextSelectionAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedFile import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachment @@ -84,6 +85,7 @@ class AttachmentViewModel @Inject constructor( val images: List = emptyList(), val files: List = emptyList(), val pageContext: PageContextAttachment? = null, + val textSelections: List = emptyList(), val imageLimitError: String? = null, val fileLimitError: String? = null, val fileSizeError: String? = null, @@ -93,7 +95,7 @@ class AttachmentViewModel @Inject constructor( val supportsImageUpload: Boolean = false, val supportedFileTypes: List = emptyList(), ) { - val hasAttachments: Boolean get() = images.isNotEmpty() || files.isNotEmpty() || pageContext != null + val hasAttachments: Boolean get() = images.isNotEmpty() || files.isNotEmpty() || pageContext != null || textSelections.isNotEmpty() val acceptedMimeTypes: List get() { val types = mutableListOf() if (supportedFileTypes.isNotEmpty()) types.addAll(supportedFileTypes) @@ -106,6 +108,7 @@ class AttachmentViewModel @Inject constructor( internal val imageAttachments = MutableStateFlow>(emptyList()) private val _fileAttachments = MutableStateFlow>(emptyList()) private val _pageContextAttachment = MutableStateFlow(null) + private val _textSelections = MutableStateFlow>(emptyList()) private val isDuckAiModeFlow: StateFlow = nativeInputStateProvider.state .map { it.inputContext != NativeInputState.InputContext.BROWSER } @@ -116,13 +119,13 @@ class AttachmentViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, DuckChatPixelSurface.ADDRESS_BAR) val attachmentState: StateFlow = combine( - combine(imageAttachments, _fileAttachments, _pageContextAttachment) { images, files, pageContext -> - Triple(images, files, pageContext) + combine(imageAttachments, _fileAttachments, _pageContextAttachment, _textSelections) { images, files, pageContext, selections -> + AttachmentLists(images, files, pageContext, selections) }, modelManager.modelState, combine(limitsHandler.conversationImagesSent, limitsHandler.conversationFilesUsed) { imgSent, filesUsed -> Pair(imgSent, filesUsed) }, isDuckAiModeFlow, - ) { (images, files, pageContext), modelState, (conversationImagesSent, conversationFilesUsed), isDuckAiMode -> + ) { (images, files, pageContext, selections), modelState, (conversationImagesSent, conversationFilesUsed), isDuckAiMode -> val conversationFilesSent = conversationFilesUsed.count val conversationFileSizeSentBytes = conversationFilesUsed.sizeBytes val model = modelState.models.find { it.id == modelState.selectedModelId } @@ -139,6 +142,7 @@ class AttachmentViewModel @Inject constructor( images = images, files = files, pageContext = pageContext, + textSelections = selections, imageLimitError = computeImageLimitError(currentImageCount, totalImages, imageLimits), fileLimitError = computeFileLimitError(totalFiles, fileLimits.maxPerConversation), fileSizeError = computeFileSizeError(files, fileLimits.maxFileSizeBytes), @@ -338,6 +342,17 @@ class AttachmentViewModel @Inject constructor( } } + private data class AttachmentLists( + val images: List, + val files: List, + val pageContext: PageContextAttachment?, + val textSelections: List, + ) + + fun setTextSelections(selections: List) { + _textSelections.value = selections + } + fun setPageContext(attachment: PageContextAttachment) { _pageContextAttachment.value = attachment } @@ -353,6 +368,7 @@ class AttachmentViewModel @Inject constructor( imageAttachments.value = emptyList() _fileAttachments.value = emptyList() _pageContextAttachment.value = null + _textSelections.value = emptyList() viewModelScope.launch { toRecycle.forEach { it.bitmap.recycle() } } } From 63c4f2d74869df9c5edb33093b13aaf70698d0f9 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:54:28 +0100 Subject: [PATCH 24/73] Remove redundant comment --- .../suggestions/ContextualSuggestedPromptsProvider.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt index 5fcafa17def2..3f8500b7d8af 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt @@ -31,11 +31,9 @@ import javax.inject.Inject interface ContextualSuggestedPromptsProvider { suspend fun resolveSuggestions(input: ResolvePageSuggestionsInput): ResolvedPageSuggestions + suspend fun resolveTextSelectionSuggestions(input: ResolvePageSuggestionsInput): List suspend fun maxSuggestedPrompts(): Int suspend fun prioritySuggestionIds(): Set - - /** The prompts offered for a single attached text selection, rather than for the page as a whole. */ - suspend fun resolveTextSelectionSuggestions(input: ResolvePageSuggestionsInput): List } @ContributesBinding(AppScope::class) From a3cae1993c13822984005f6a7d02724b4622aa77 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Mon, 7 Sep 2026 23:55:40 +0100 Subject: [PATCH 25/73] Refactor --- .../ContextualSuggestedPromptsProvider.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt index 3f8500b7d8af..0e1d0a0ad38f 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestedPromptsProvider.kt @@ -71,6 +71,14 @@ class RealContextualSuggestedPromptsProvider @Inject constructor( ) } + override suspend fun resolveTextSelectionSuggestions( + input: ResolvePageSuggestionsInput, + ): List = withContext(dispatcherProvider.io()) { + val catalog = bundledCatalog ?: return@withContext emptyList() + ContextualSuggestionsMatcher.resolveIds(TEXT_SELECTION_SUGGESTION_IDS, input, catalog) + .map { localize(it, input) } + } + private fun localize( suggestion: ContextualSuggestedPrompt, input: ResolvePageSuggestionsInput, @@ -82,14 +90,6 @@ class RealContextualSuggestedPromptsProvider @Inject constructor( ) } - override suspend fun resolveTextSelectionSuggestions( - input: ResolvePageSuggestionsInput, - ): List = withContext(dispatcherProvider.io()) { - val catalog = bundledCatalog ?: return@withContext emptyList() - ContextualSuggestionsMatcher.resolveIds(TEXT_SELECTION_SUGGESTION_IDS, input, catalog) - .map { localize(it, input) } - } - override suspend fun maxSuggestedPrompts(): Int = withContext(dispatcherProvider.io()) { bundledCatalog?.maxSuggestedPrompts ?: 1 } From bc3b6a9a920a991a9d6e01f1acb718119f51bc60 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:14:53 +0100 Subject: [PATCH 26/73] Update tests --- .../nativeinput/RealNativeInputManagerTest.kt | 8 +- .../DuckChatContextualEntryViewModelTest.kt | 143 +++++++++++++++++- .../contextual/RealDuckChatContextualTest.kt | 3 + .../ContextualSuggestionsViewModelTest.kt | 25 +++ 4 files changed, 176 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt index a88c916fa0e1..a28ec415c3a3 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt @@ -43,6 +43,8 @@ import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.DuckChatInputModeState import com.duckduckgo.duckchat.api.NativeInputEventListener import com.duckduckgo.duckchat.api.nativeinput.NativeInputState +import com.duckduckgo.duckchat.impl.contextual.ContextualTextSelectionPayloadBuilder +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore import com.duckduckgo.duckchat.impl.ui.nativeinput.views.NativeInputWidget import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State @@ -83,6 +85,8 @@ class RealNativeInputManagerTest { private val nativeInputEventListener: NativeInputEventListener = mock() private val edgeToEdgeProvider: EdgeToEdgeProvider = mock() private val edgeToEdgeHandler = EdgeToEdgeHandler() + private val textSelectionStore = RealTextSelectionStore() + private val selectionPayloadBuilder: ContextualTextSelectionPayloadBuilder = mock() private val nativeInputStateBugKillSwitch = FakeFeatureToggleFactory.create(NativeInputStateBugKillSwitch::class.java) private val nativeInputUrlClearingFeature = FakeFeatureToggleFactory.create(NativeInputUrlClearingFeature::class.java) private val nativeInputOmnibarFeature = FakeFeatureToggleFactory.create(NativeInputOmnibarFeature::class.java) @@ -118,6 +122,8 @@ class RealNativeInputManagerTest { nativeInputEventListener, edgeToEdgeProvider, edgeToEdgeHandler, + textSelectionStore, + selectionPayloadBuilder, ) } @@ -424,7 +430,7 @@ class RealNativeInputManagerTest { callbacks = NativeInputCallbacks( onSearchTextChanged = {}, onSearchSubmitted = {}, - onDuckAiChatSubmitted = { _, _, _, _, _, _ -> }, + onDuckAiChatSubmitted = { _, _, _, _, _, _, _ -> }, onChatSuggestionSelected = {}, onDuckAiQuerySubmitted = onDuckAiQuerySubmitted, onClearAutocomplete = {}, diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt index c87bad0f8541..5286ce3fcd79 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt @@ -16,15 +16,22 @@ package com.duckduckgo.duckchat.impl.contextual +import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.duckchat.impl.models.DuckAiModelManager import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionTarget +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -32,13 +39,22 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.robolectric.RuntimeEnvironment +@RunWith(AndroidJUnit4::class) class DuckChatContextualEntryViewModelTest { private val store: ContextualEntryPromptStore = mock() private val duckChatPixels: DuckChatPixels = mock() private val modelManager: DuckAiModelManager = mock() - private val viewModel = DuckChatContextualEntryViewModel(store, duckChatPixels, modelManager) + private val textSelectionStore = RealTextSelectionStore() + private val viewModel = DuckChatContextualEntryViewModel( + store, + duckChatPixels, + modelManager, + textSelectionStore, + RealContextualTextSelectionPayloadBuilder(RuntimeEnvironment.getApplication()), + ) private val validContext = """{"title":"Example","url":"https://example.com","content":"some page content"}""" private val samplePrompt = NativeInputPrompt("hi", "model-1", "high", "tool-1", null, null) @@ -235,7 +251,6 @@ class DuckChatContextualEntryViewModelTest { @Test fun whenPromptSubmittedThenReportsFloatingInputPromotedToSheet() = runTest { viewModel.start("tab-1") - viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) assertEquals(DuckChatContextualEntryViewModel.Command.HandOffToSheet, awaitItem()) @@ -278,4 +293,128 @@ class DuckChatContextualEntryViewModelTest { verify(duckChatPixels).reportContextualPageContextRemovedNative() } + + @Test + fun whenTextSelectionAttachedThenPageContextNotAttached() = runTest { + viewModel.start("tab-1") + textSelectionStore.store("selected words", TextSelectionTarget.CONTEXTUAL) + textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + + viewModel.onPageContextReceived(validContext) + + assertNull(viewModel.viewState.value.attachedContext) + } + + @Test + fun whenPromptSubmittedWithTextSelectionsThenSelectionsSentOnOwnKeyAndCleared() = runTest { + viewModel.start("tab-1") + viewModel.onPageContextReceived(validContext) + textSelectionStore.store("first selection", TextSelectionTarget.CONTEXTUAL) + textSelectionStore.store("second selection", TextSelectionTarget.CONTEXTUAL) + textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + + viewModel.commands.test { + viewModel.onPromptSubmitted(samplePrompt) + assertEquals(DuckChatContextualEntryViewModel.Command.HandOffToSheet, awaitItem()) + } + + val captor = argumentCaptor() + verify(store).store(captor.capture()) + val selections = captor.firstValue.selectionsJson!! + assertEquals(2, selections.length()) + val first = selections.getJSONObject(0) + assertEquals("first selection", first.getString("content")) + assertEquals("https://example.com", first.getString("url")) + assertEquals(2, first.getInt("wordCount")) + assertEquals(15, first.getInt("fullContentLength")) + assertFalse(first.getBoolean("truncated")) + assertTrue(textSelectionStore.selections("tab-1").value.isEmpty()) + } + + @Test + fun whenPromptSubmittedWithoutTextSelectionsThenNoSelectionsKey() = runTest { + viewModel.start("tab-1") + viewModel.onPageContextReceived(validContext) + + viewModel.commands.test { + viewModel.onPromptSubmitted(samplePrompt) + assertEquals(DuckChatContextualEntryViewModel.Command.HandOffToSheet, awaitItem()) + } + + val captor = argumentCaptor() + verify(store).store(captor.capture()) + assertNull(captor.firstValue.selectionsJson) + assertEquals(validContext, captor.firstValue.serializedPageContext) + } + + @Test + fun whenSelectionExceedsMaxContentLengthThenTruncatedButSizeReported() = runTest { + val long = "word ".repeat(3000) + viewModel.start("tab-1") + textSelectionStore.store(long, TextSelectionTarget.CONTEXTUAL) + textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + + viewModel.commands.test { + viewModel.onPromptSubmitted(samplePrompt) + assertEquals(DuckChatContextualEntryViewModel.Command.HandOffToSheet, awaitItem()) + } + + val captor = argumentCaptor() + verify(store).store(captor.capture()) + val selection = captor.firstValue.selectionsJson!!.getJSONObject(0) + assertTrue(selection.getBoolean("truncated")) + assertEquals(ContextualTextSelectionPayloadBuilder.MAX_CONTENT_LENGTH, selection.getString("content").length) + assertEquals(long.trim().length, selection.getInt("fullContentLength")) + assertEquals(3000, selection.getInt("wordCount")) + } + + @Test + fun whenSelectionsExceedMaxThenExtrasDropped() { + repeat( + TextSelectionStore.MAX_SELECTIONS + 2, + ) { index -> textSelectionStore.store("selection $index", TextSelectionTarget.CONTEXTUAL) } + textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + + assertEquals(TextSelectionStore.MAX_SELECTIONS, textSelectionStore.consume("tab-1").size) + } + + @Test + fun whenSelectionRemovedThenDroppedFromStore() = runTest { + textSelectionStore.store("keep me", TextSelectionTarget.CONTEXTUAL) + textSelectionStore.store("remove me", TextSelectionTarget.CONTEXTUAL) + textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + val target = textSelectionStore.selections("tab-1").value.last() + + viewModel.start("tab-1") + viewModel.onTextSelectionRemoved(target.id) + + assertEquals(listOf("keep me"), textSelectionStore.consume("tab-1").map { it.text }) + } + + @Test + fun whenNoPendingSelectionThenClaimReportsNothing() { + assertFalse(textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL)) + } + + @Test + fun whenSelectionTargetsFullScreenThenOnlyAFullScreenSurfaceClaimsIt() { + textSelectionStore.store("from another app", TextSelectionTarget.FULL_SCREEN) + + // The browser tab that happens to resume first must not take it. + assertFalse(textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL)) + assertTrue(textSelectionStore.selections("tab-1").value.isEmpty()) + + assertTrue(textSelectionStore.claim("duck-ai-tab", TextSelectionTarget.FULL_SCREEN)) + assertEquals(listOf("from another app"), textSelectionStore.consume("duck-ai-tab").map { it.text }) + } + + @Test + fun whenTargetSwitchesThenEarlierPendingSelectionsDropped() { + textSelectionStore.store("in app", TextSelectionTarget.CONTEXTUAL) + + textSelectionStore.store("from another app", TextSelectionTarget.FULL_SCREEN) + textSelectionStore.claim("tab-1", TextSelectionTarget.FULL_SCREEN) + + assertEquals(listOf("from another app"), textSelectionStore.consume("tab-1").map { it.text }) + } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt index c591a60608a2..3c0ff1909874 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.store.DuckChatContextualDataStore import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test @@ -41,6 +42,7 @@ class RealDuckChatContextualTest { private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private val contextualEntryPromptStore = RealContextualEntryPromptStore() private val globalActivityStarter: GlobalActivityStarter = mock() + private val textSelectionStore = RealTextSelectionStore() private val anchor: View = mock() private val testee = RealDuckChatContextual( @@ -53,6 +55,7 @@ class RealDuckChatContextualTest { duckDuckGoUrlDetector, contextualEntryPromptStore, globalActivityStarter, + textSelectionStore, ) @Test diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt index ebb4f69a413e..8b136c72292d 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt @@ -442,4 +442,29 @@ class ContextualSuggestionsViewModelTest { assertEquals(SuggestionsPageType.RECIPE, viewModel.currentPageType()) } + + @Test + fun whenSingleTextSelectionAttachedThenSelectionSuggestionsShown() = runTest { + val selectionSuggestions = listOf( + ContextualSuggestedPrompt("summarize-selection", "Summarize this selection", "Summarize this selection.", "summary"), + ContextualSuggestedPrompt("translate-selection", "Translate this selection", "Translate this selection into English.", "translate"), + ) + whenever(suggestedPromptsProvider.resolveTextSelectionSuggestions(any())).thenReturn(selectionSuggestions) + + viewModel.onTextSelectionCountChanged(1) + + assertEquals(selectionSuggestions, viewModel.viewState.value.suggestions) + } + + @Test + fun whenMoreThanOneTextSelectionAttachedThenNoSuggestionsShown() = runTest { + whenever(suggestedPromptsProvider.resolveTextSelectionSuggestions(any())).thenReturn( + listOf(ContextualSuggestedPrompt("summarize-selection", "Summarize this selection", "Summarize this selection.", "summary")), + ) + viewModel.onTextSelectionCountChanged(1) + + viewModel.onTextSelectionCountChanged(2) + + assertTrue(viewModel.viewState.value.suggestions.isEmpty()) + } } From 5b99e74e889b91dcb6f80f6d3c072449cad08ce0 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:24:13 +0100 Subject: [PATCH 27/73] Add payload builder --- .../TextSelectionPayloadBuilder.kt | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt new file mode 100644 index 000000000000..e5b171366cfc --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt @@ -0,0 +1,74 @@ +/* + * 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.impl.ui.nativeinput.textselection + +import android.content.Context +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.impl.R +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import org.json.JSONArray +import org.json.JSONObject +import javax.inject.Inject + +interface TextSelectionPayloadBuilder { + fun toJson(selections: List, url: String): JSONArray? + + companion object { + const val MAX_CONTENT_LENGTH = 9500 + } +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealTextSelectionPayloadBuilder @Inject constructor( + private val context: Context, +) : TextSelectionPayloadBuilder { + + override fun toJson( + selections: List, + url: String, + ): JSONArray? { + val title = context.getString(R.string.duckAiTextSelectionAttachmentTitle) + if (selections.isEmpty()) return null + return JSONArray().apply { + selections.forEach { selection -> put(toJson(selection, title, url)) } + } + } + + private fun toJson( + selection: TextSelection, + title: String, + url: String, + ): JSONObject { + val truncated = selection.text.length > TextSelectionPayloadBuilder.MAX_CONTENT_LENGTH + return JSONObject().apply { + put("id", selection.id) + put("title", title) + put("favicon", JSONArray()) + put("url", url) + put("content", if (truncated) selection.text.take(TextSelectionPayloadBuilder.MAX_CONTENT_LENGTH) else selection.text) + put("truncated", truncated) + put("fullContentLength", selection.text.length) + put("wordCount", selection.text.split(WHITESPACE).count { it.isNotEmpty() }) + } + } + + private companion object { + val WHITESPACE = Regex("\\s+") + } +} From 22692201d7371e637ec2bce8f89d918849986a68 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:32:07 +0100 Subject: [PATCH 28/73] Add TextSelectionToggler --- .../textselection/TextSelectionToggler.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionToggler.kt diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionToggler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionToggler.kt new file mode 100644 index 000000000000..23b95b9ad12b --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionToggler.kt @@ -0,0 +1,63 @@ +/* + * 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.impl.ui.nativeinput.textselection + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckAiFeatureState +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@SingleInstanceIn(AppScope::class) +@ContributesMultibinding( + scope = AppScope::class, + boundType = MainProcessLifecycleObserver::class, +) +class TextSelectionToggler @Inject constructor( + private val context: Context, + private val duckAiFeatureState: DuckAiFeatureState, + private val appBuildConfig: AppBuildConfig, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, +) : MainProcessLifecycleObserver { + + override fun onCreate(owner: LifecycleOwner) { + duckAiFeatureState.showTextSelectionAction + .onEach { enabled -> withContext(dispatchers.io()) { setEnabled(enabled) } } + .launchIn(appCoroutineScope) + } + + private fun setEnabled(enabled: Boolean) { + context.packageManager.setComponentEnabledSetting( + ComponentName(appBuildConfig.applicationId, SelectedTextDuckAiActivity::class.java.name), + if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP, + ) + } +} From aa7c89e9b45e7ea356048e4c22f9408f6a69544a Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:32:39 +0100 Subject: [PATCH 29/73] Update tests --- .../app/browser/nativeinput/RealNativeInputManagerTest.kt | 4 ++-- .../contextual/DuckChatContextualEntryViewModelTest.kt | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt index a28ec415c3a3..6961fbb04811 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt @@ -43,7 +43,7 @@ import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.DuckChatInputModeState import com.duckduckgo.duckchat.api.NativeInputEventListener import com.duckduckgo.duckchat.api.nativeinput.NativeInputState -import com.duckduckgo.duckchat.impl.contextual.ContextualTextSelectionPayloadBuilder +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore import com.duckduckgo.duckchat.impl.ui.nativeinput.views.NativeInputWidget import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory @@ -86,7 +86,7 @@ class RealNativeInputManagerTest { private val edgeToEdgeProvider: EdgeToEdgeProvider = mock() private val edgeToEdgeHandler = EdgeToEdgeHandler() private val textSelectionStore = RealTextSelectionStore() - private val selectionPayloadBuilder: ContextualTextSelectionPayloadBuilder = mock() + private val selectionPayloadBuilder: TextSelectionPayloadBuilder = mock() private val nativeInputStateBugKillSwitch = FakeFeatureToggleFactory.create(NativeInputStateBugKillSwitch::class.java) private val nativeInputUrlClearingFeature = FakeFeatureToggleFactory.create(NativeInputUrlClearingFeature::class.java) private val nativeInputOmnibarFeature = FakeFeatureToggleFactory.create(NativeInputOmnibarFeature::class.java) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt index 5286ce3fcd79..688d48b15c27 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt @@ -22,9 +22,11 @@ import com.duckduckgo.duckchat.impl.models.DuckAiModelManager import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionTarget +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionPayloadBuilder import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionTarget import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -53,7 +55,7 @@ class DuckChatContextualEntryViewModelTest { duckChatPixels, modelManager, textSelectionStore, - RealContextualTextSelectionPayloadBuilder(RuntimeEnvironment.getApplication()), + RealTextSelectionPayloadBuilder(RuntimeEnvironment.getApplication()), ) private val validContext = """{"title":"Example","url":"https://example.com","content":"some page content"}""" @@ -363,7 +365,7 @@ class DuckChatContextualEntryViewModelTest { verify(store).store(captor.capture()) val selection = captor.firstValue.selectionsJson!!.getJSONObject(0) assertTrue(selection.getBoolean("truncated")) - assertEquals(ContextualTextSelectionPayloadBuilder.MAX_CONTENT_LENGTH, selection.getString("content").length) + assertEquals(TextSelectionPayloadBuilder.MAX_CONTENT_LENGTH, selection.getString("content").length) assertEquals(long.trim().length, selection.getInt("fullContentLength")) assertEquals(3000, selection.getInt("wordCount")) } From 543018315a9db507b720b8a84485ba02d2149f93 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:33:23 +0100 Subject: [PATCH 30/73] Add text selection to DuckChatContextual --- .../main/java/com/duckduckgo/duckchat/api/DuckChatContextual.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatContextual.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatContextual.kt index 0592afcf2dca..74a79e5eb099 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatContextual.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatContextual.kt @@ -39,6 +39,7 @@ interface DuckChatContextual { sourceTabId: String, sourceUrl: String?, anchor: View?, + textSelection: String? = null, showChatSurface: () -> Unit, ) From f69b7126b75c09d1adbf0c7d61e9563282ffe998 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:37:17 +0100 Subject: [PATCH 31/73] Update impl --- .../duckchat/impl/contextual/RealDuckChatContextual.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt index 5172ad952ad7..73ccb44f002c 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt @@ -37,6 +37,7 @@ import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.store.DuckChatContextualDataStore import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -51,18 +52,25 @@ class RealDuckChatContextual @Inject constructor( private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, private val contextualEntryPromptStore: ContextualEntryPromptStore, private val globalActivityStarter: GlobalActivityStarter, + private val textSelectionStore: TextSelectionStore, ) : DuckChatContextual { override suspend fun launch( sourceTabId: String, sourceUrl: String?, anchor: View?, + textSelection: String?, showChatSurface: () -> Unit, ) { if (anchor == null || !duckChatInternal.isContextualSheetRedesignEnabled()) { showChatSurface() return } + textSelection?.let { textSelectionStore.add(sourceTabId, it) } + if (textSelectionStore.selections(sourceTabId).value.isNotEmpty()) { + showEntryDialog(anchor, sourceTabId, showChatSurface) + return + } if (sourceUrl == null && !duckChatInternal.isContextualMenuAllChatsEnabled()) { // Nothing to ask about and no Chats entry, so a one-item menu would be worse than the // caller's own fallback (opening Duck.ai). @@ -136,6 +144,7 @@ class RealDuckChatContextual @Inject constructor( } else { popup.onMenuItemClicked(askItem) { duckChatPixels.reportContextualAddressBarMenuAskAboutPageSelected() + textSelectionStore.consume(sourceTabId) showEntryDialog(anchor, sourceTabId, onAskAboutPage) } } From 6b9e86f14748a4e3423b6c5624940c41628c6f5a Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:39:05 +0100 Subject: [PATCH 32/73] Wire up manager --- .../browser/nativeinput/NativeInputManager.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt index c14568708b79..04bb0872edc7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt @@ -90,6 +90,7 @@ class NativeInputCallbacks( selectedTool: String?, imagesJson: JSONArray?, filesJson: JSONArray?, + selectionsJson: JSONArray?, ) -> Unit, val onChatSuggestionSelected: (String) -> Unit, val onDuckAiQuerySubmitted: (query: String, entryPoint: DuckChatEntryPoint) -> Unit = { _, _ -> }, @@ -148,6 +149,7 @@ interface NativeInputManager { callbacks: NativeInputCallbacks, initialInputMode: InputMode? = null, forceImageGeneration: Boolean = false, + textSelection: String? = null, ) fun hideNativeInput(animate: Boolean = true, isNavigation: Boolean = false): Boolean @@ -588,6 +590,7 @@ class RealNativeInputManager @Inject constructor( callbacks: NativeInputCallbacks, initialInputMode: InputMode?, forceImageGeneration: Boolean, + textSelection: String?, ) { if (!isNativeInputFieldEnabled) return @@ -657,7 +660,7 @@ class RealNativeInputManager @Inject constructor( } } bindUrlCaching(widgetView) - attachWidget(widgetView, navBarView, isBottom, tabId, forceImageGeneration) + attachWidget(widgetView, navBarView, isBottom, tabId, forceImageGeneration, textSelection) // Bottom omnibar: slide the nav bar in with open. Top omnibar: snap the bar so the enter // morph can run from the omnibar while the buttons appear without animating — a concurrent // top slide fights that morph (and was only needed for bottom chrome). @@ -715,6 +718,7 @@ class RealNativeInputManager @Inject constructor( widget.saveLastUsedTogglePosition(isChat = true) val imagesJson = widget.getImageAttachmentsJson() val filesJson = widget.getFileAttachmentsJson() + val selectionsJson = widget.getTextSelectionsJson() widget.text = "" widget.clearAttachments() callbacks.onDuckAiChatSubmitted( @@ -724,6 +728,7 @@ class RealNativeInputManager @Inject constructor( widget.getSelectedTool(), imagesJson, filesJson, + selectionsJson, ) widget.clearSelectedTool() widget.onPromptSubmitted() @@ -1231,7 +1236,14 @@ class RealNativeInputManager @Inject constructor( ) } - private fun attachWidget(widgetView: View, navBarView: View?, isBottom: Boolean, tabId: String, forceImageGeneration: Boolean) { + private fun attachWidget( + widgetView: View, + navBarView: View?, + isBottom: Boolean, + tabId: String, + forceImageGeneration: Boolean, + textSelection: String?, + ) { // Inflated from a ?attr/actionBarSize height, so layoutParams carries the resolved nav bar height. val navBarHeightPx = navBarView?.layoutParams?.height?.takeIf { it > 0 } ?: 0 this.navBarHeightPx = navBarHeightPx @@ -1265,6 +1277,7 @@ class RealNativeInputManager @Inject constructor( isBottom = isBottom, forceImageGeneration = forceImageGeneration, ) + bindTextSelections(tabId, textSelection) } applyWindowChrome(widgetView, isBottom) From 742c8fabd3d0f5130e0fcd5110d71639cd89b22e Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:40:54 +0100 Subject: [PATCH 33/73] Add selections to widget --- .../views/NativeInputModeWidget.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index b6a1d7a5d3fc..95f57515f1fb 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -87,9 +87,12 @@ import com.duckduckgo.duckchat.impl.pixel.inputScreenPixelsModeParam import com.duckduckgo.duckchat.impl.store.DefaultTogglePosition import com.duckduckgo.duckchat.impl.ui.NativeInputModeWidgetViewModel import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.PageContextAttachment +import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.TextSelectionAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.EditPromptScreenParams import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedFile import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore import com.duckduckgo.navigation.api.GlobalActivityStarter import com.google.android.material.card.MaterialCardView import com.google.android.material.shape.ShapeAppearanceModel @@ -193,6 +196,11 @@ interface NativeInputWidget { fun setWidgetPosition(isBottom: Boolean) fun setWidgetRootView(view: View) + fun setTextSelections(selections: List) + fun setTextSelectionRemovedAction(onTextSelectionRemoved: (String) -> Unit) + fun bindTextSelections(tabId: String, textSelection: String?) + fun getTextSelectionsJson(): JSONArray? + /** * Binds a reactive source of the active chat id for this tab. * The widget forwards changes into the [NativeInputState] so observers can react. @@ -262,6 +270,12 @@ class NativeInputModeWidget @JvmOverloads constructor( @Inject lateinit var pixel: Pixel + @Inject + lateinit var textSelectionStore: TextSelectionStore + + @Inject + lateinit var selectionPayloadBuilder: TextSelectionPayloadBuilder + @Inject lateinit var duckChatInternal: DuckChatInternal @@ -392,6 +406,10 @@ class NativeInputModeWidget @JvmOverloads constructor( private var pendingAskAboutPage: (() -> Unit)? = null private var pendingOnPageContextRemoved: (() -> Unit)? = null private var pendingPageContext: PageContextAttachment? = null + private var pendingTextSelections: List = emptyList() + private var boundTextSelectionsTabId: String? = null + private var textSelectionsJob: Job? = null + private var pendingOnTextSelectionRemoved: ((String) -> Unit)? = null // adoptEditAttachments() can be called (from EditPromptActivity.onCreate) before the widget is // attached and the AttachmentView plugin exists, so the values are held here and applied once @@ -788,8 +806,10 @@ class NativeInputModeWidget @JvmOverloads constructor( pluginView.isEditMode = isEditWidget pluginView.onAskAboutPage = pendingAskAboutPage pluginView.onPageContextRemoved = pendingOnPageContextRemoved + pluginView.onTextSelectionRemoved = pendingOnTextSelectionRemoved pluginView.bind(scope, viewModelFactory, nativeInputStateProvider, faviconManager) pendingPageContext?.let { pluginView.setPageContext(it) } + pluginView.setTextSelections(pendingTextSelections) if (hasPendingAdoptedAttachments(pendingAdoptedImages, pendingAdoptedFiles)) { pluginView.adoptAttachments(pendingAdoptedImages, pendingAdoptedFiles) } @@ -1647,6 +1667,33 @@ class NativeInputModeWidget @JvmOverloads constructor( override fun getPageContext(): PageContextAttachment? = attachmentView?.getPageContext() + override fun setTextSelections(selections: List) { + pendingTextSelections = selections + attachmentView?.setTextSelections(selections) + } + + override fun setTextSelectionRemovedAction(onTextSelectionRemoved: (String) -> Unit) { + pendingOnTextSelectionRemoved = onTextSelectionRemoved + attachmentView?.onTextSelectionRemoved = onTextSelectionRemoved + } + + override fun bindTextSelections(tabId: String, textSelection: String?) { + boundTextSelectionsTabId = tabId + textSelection?.let { textSelectionStore.add(tabId, it) } + setTextSelectionRemovedAction { id -> textSelectionStore.remove(tabId, id) } + textSelectionsJob?.cancel() + textSelectionsJob = findViewTreeLifecycleOwner()?.lifecycleScope?.launch { + textSelectionStore.selections(tabId).collect { selections -> + setTextSelections(selections.map { TextSelectionAttachment(id = it.id, text = it.text) }) + } + } + } + + override fun getTextSelectionsJson(): JSONArray? { + val tabId = boundTextSelectionsTabId ?: return null + return selectionPayloadBuilder.toJson(selections = textSelectionStore.consume(tabId), url = "") + } + override fun setContextualAttachmentActions( onAskAboutPage: () -> Unit, onPageContextRemoved: () -> Unit, From 01948e45abf29157ba1029b3f500633001aad6b7 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:46:50 +0100 Subject: [PATCH 34/73] Update entry dialog --- .../DuckChatContextualEntryDialog.kt | 8 +++- .../DuckChatContextualEntryViewModel.kt | 39 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt index d4d714693a0d..9eee3386f6e0 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt @@ -53,6 +53,7 @@ import com.duckduckgo.duckchat.impl.databinding.DialogContextualDuckAiEntryBindi import com.duckduckgo.duckchat.impl.feature.DuckChatFeature import com.duckduckgo.duckchat.impl.ui.filechooser.FileChooserIntentBuilder import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher +import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.TextSelectionAttachment import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.voice.api.VoiceSearchLauncher import com.duckduckgo.voice.api.VoiceSearchLauncher.Source.BROWSER @@ -223,6 +224,10 @@ class DuckChatContextualEntryDialog : DuckDuckGoBottomSheetDialogFragment() { } else { binding.entryNativeInputWidget.clearPageContext() } + binding.entryNativeInputWidget.setTextSelections( + state.textSelections.map { TextSelectionAttachment(id = it.id, text = it.text) }, + ) + binding.entrySuggestionsView.onTextSelectionCountChanged(state.textSelections.size) updateQuickActionVisibility() } @@ -341,7 +346,7 @@ class DuckChatContextualEntryDialog : DuckDuckGoBottomSheetDialogFragment() { } .launchIn(viewLifecycleOwner.lifecycleScope) viewModel.viewState - .map { it.attachedContext?.serialized } + .map { it.latestPageContext } .filterNotNull() .distinctUntilChanged() .onEach { binding.entrySuggestionsView.onPageContextUpdated(it) } @@ -367,6 +372,7 @@ class DuckChatContextualEntryDialog : DuckDuckGoBottomSheetDialogFragment() { onFilePickerRequested = { callback, mimeTypes -> launchFilePicker(callback, mimeTypes) }, onAskAboutPage = { viewModel.onAttachContextRequested() }, onPageContextRemoved = { viewModel.onContextRemoved() }, + onTextSelectionRemoved = { id -> viewModel.onTextSelectionRemoved(id) }, onVoiceChatRequested = { duckChat.openVoiceDuckChat(DuckChatEntryPoint.VOICE) dismiss() diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt index 6e82a7d8b664..59e07e02b7f7 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt @@ -17,17 +17,23 @@ package com.duckduckgo.duckchat.impl.contextual import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.duckchat.impl.models.DuckAiModelManager import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelection +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import org.json.JSONObject @@ -43,10 +49,14 @@ class DuckChatContextualEntryViewModel @Inject constructor( private val contextualEntryPromptStore: ContextualEntryPromptStore, private val duckChatPixels: DuckChatPixels, private val modelManager: DuckAiModelManager, + private val textSelectionStore: TextSelectionStore, + private val selectionPayloadBuilder: TextSelectionPayloadBuilder, ) : ViewModel() { data class ViewState( val attachedContext: AttachedPageContext? = null, + val latestPageContext: String? = null, + val textSelections: List = emptyList(), ) data class AttachedPageContext( @@ -77,18 +87,27 @@ class DuckChatContextualEntryViewModel @Inject constructor( fun start(tabId: String) { this.tabId = tabId duckChatPixels.reportContextualFloatingInputShown() + textSelectionStore.selections(tabId) + .onEach { selections -> _viewState.update { it.copy(textSelections = selections) } } + .launchIn(viewModelScope) + } + + fun onTextSelectionRemoved(id: String) { + textSelectionStore.remove(tabId, id) } fun onPageContextReceived(serializedPageContext: String) { if (!isContextValid(serializedPageContext)) return latestValidPageContext = serializedPageContext + _viewState.update { it.copy(latestPageContext = serializedPageContext) } // The dialog is only shown from "Ask about page", so attach regardless of the auto-attach feature // flag — unless the user explicitly removed the context this session. - if (!userRemovedContext) attach(serializedPageContext) + if (!userRemovedContext && !hasTextSelections()) attach(serializedPageContext) } /** The composer's "attach page context" affordance (shown when nothing is attached). */ fun onAttachContextRequested() { + if (hasTextSelections()) return latestValidPageContext?.let { duckChatPixels.reportContextualPageContextManuallyAttachedNative() attach(it) @@ -103,7 +122,7 @@ class DuckChatContextualEntryViewModel @Inject constructor( /** A suggested prompt was picked; suggestions are page-specific, so attach the context before submit. */ fun onSuggestionSubmitted(prompt: NativeInputPrompt) { - if (_viewState.value.attachedContext == null) latestValidPageContext?.let { attach(it) } + if (_viewState.value.attachedContext == null && !hasTextSelections()) latestValidPageContext?.let { attach(it) } fireUnifiedInputPromptSubmitted() submit(prompt) } @@ -134,13 +153,27 @@ class DuckChatContextualEntryViewModel @Inject constructor( } private fun submit(prompt: NativeInputPrompt) { + val selections = textSelectionStore.consume(tabId) contextualEntryPromptStore.store( - ContextualEntryPrompt(tabId, prompt, _viewState.value.attachedContext?.serialized), + ContextualEntryPrompt( + tabId = tabId, + prompt = prompt, + serializedPageContext = _viewState.value.attachedContext?.serialized, + selectionsJson = selectionPayloadBuilder.toJson(selections = selections, url = pageUrl()), + ), ) duckChatPixels.reportContextualFloatingInputPromotedToSheet() commandChannel.trySend(Command.HandOffToSheet) } + private fun pageUrl(): String = + latestValidPageContext + ?.let { runCatching { JSONObject(it) }.getOrNull() } + ?.optString("url") + .orEmpty() + + private fun hasTextSelections(): Boolean = textSelectionStore.selections(tabId).value.isNotEmpty() + private fun attach(serializedPageContext: String) { val json = runCatching { JSONObject(serializedPageContext) }.getOrNull() ?: return userRemovedContext = false From 86527641debef3f47fa11e786116d60d597e59d0 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:47:34 +0100 Subject: [PATCH 35/73] Wire up contextual manager --- .../impl/contextual/ContextualNativeInputManager.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt index fa7575d76b9a..759ec956f138 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt @@ -73,6 +73,7 @@ interface ContextualNativeInputManager { onPromptSubmitted: (NativeInputPrompt) -> Unit = {}, onAskAboutPage: () -> Unit = {}, onPageContextRemoved: () -> Unit = {}, + onTextSelectionRemoved: (String) -> Unit = {}, onVoiceChatRequested: () -> Unit = {}, onVoiceSearchRequested: () -> Unit = {}, ) @@ -128,6 +129,7 @@ class RealContextualNativeInputManager @Inject constructor( onPromptSubmitted: (NativeInputPrompt) -> Unit, onAskAboutPage: () -> Unit, onPageContextRemoved: () -> Unit, + onTextSelectionRemoved: (String) -> Unit, onVoiceChatRequested: () -> Unit, onVoiceSearchRequested: () -> Unit, ) { @@ -140,7 +142,7 @@ class RealContextualNativeInputManager @Inject constructor( tabId, widget, chatIdFlow, onSearchSubmitted, onCameraCaptureRequested, onFilePickerRequested, onPromptSubmitted, onAskAboutPage, onPageContextRemoved, - onVoiceChatRequested, onVoiceSearchRequested, + onTextSelectionRemoved, onVoiceChatRequested, onVoiceSearchRequested, ) observeNativeInputSetting(lifecycleOwner) observeVoiceChatEntry(widget, lifecycleOwner) @@ -215,6 +217,7 @@ class RealContextualNativeInputManager @Inject constructor( onPromptSubmitted: (NativeInputPrompt) -> Unit, onAskAboutPage: () -> Unit, onPageContextRemoved: () -> Unit, + onTextSelectionRemoved: (String) -> Unit, onVoiceChatRequested: () -> Unit, onVoiceSearchRequested: () -> Unit, ) { @@ -233,6 +236,7 @@ class RealContextualNativeInputManager @Inject constructor( onAskAboutPage = onAskAboutPage, onPageContextRemoved = onPageContextRemoved, ) + widget.setTextSelectionRemovedAction(onTextSelectionRemoved) widget.bindInputEvents( onSearchTextChanged = { }, onSearchSubmitted = { query -> From ff79df158887427d4335b8459260cb51c19f41d7 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:48:07 +0100 Subject: [PATCH 36/73] Update suggestions logic --- .../suggestions/ContextualSuggestionsView.kt | 6 +++ .../ContextualSuggestionsViewModel.kt | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsView.kt index b2f2fac68c33..16cec9aabe12 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsView.kt @@ -115,6 +115,12 @@ class ContextualSuggestionsView @JvmOverloads constructor( } } + fun onTextSelectionCountChanged(count: Int) { + doOnAttach { + viewModel.onTextSelectionCountChanged(count) + } + } + fun setReservedQuickActionSlots(count: Int) { doOnAttach { viewModel.onReservedQuickActionSlotsChanged(count) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt index 716ad52f166c..6a30ec475bab 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt @@ -59,6 +59,8 @@ class ContextualSuggestionsViewModel @Inject constructor( private var pageType: SuggestionsPageType = SuggestionsPageType.NONE private var isSmart: Boolean = false private var suggestionsVisible = false + private var textSelectionCount: Int = 0 + private var lastInput: ResolvePageSuggestionsInput? = null fun load() { loadJob?.cancel() @@ -100,9 +102,42 @@ class ContextualSuggestionsViewModel @Inject constructor( fun clear() { loadJob?.cancel() resolvedSuggestions = emptyList() + textSelectionCount = 0 hideSuggestions() } + fun onTextSelectionCountChanged(count: Int) { + if (textSelectionCount == count) return + textSelectionCount = count + loadJob?.cancel() + loadJob = viewModelScope.launch { + if (!suggestionsEnabled()) { + hideSuggestions() + return@launch + } + when (count) { + 0 -> { + fetchSuggestions(lastInput?.url, lastInput?.pageTypeSignals) + showSuggestions() + } + 1 -> resolveTextSelectionSuggestions() + else -> hideSuggestions() + } + } + } + + private suspend fun resolveTextSelectionSuggestions() { + resolvedSuggestions = suggestedPromptsProvider.resolveTextSelectionSuggestions(currentInput()) + showSuggestions() + } + + private fun currentInput(): ResolvePageSuggestionsInput = + lastInput ?: ResolvePageSuggestionsInput( + pageTypeSignals = null, + url = null, + uiLocale = Locale.getDefault().toLanguageTag(), + ) + fun onReservedQuickActionSlotsChanged(count: Int) { if (reservedQuickActionSlots == count) return reservedQuickActionSlots = count @@ -110,6 +145,8 @@ class ContextualSuggestionsViewModel @Inject constructor( } private fun visibleSuggestions(): List { + if (textSelectionCount == 1) return resolvedSuggestions.take(MAX_TEXT_SELECTION_SUGGESTIONS) + if (textSelectionCount > 1) return emptyList() val capacity = (maxSuggestedPrompts - reservedQuickActionSlots).coerceAtLeast(0) if (resolvedSuggestions.size <= capacity) return resolvedSuggestions val prioritySuggestions = resolvedSuggestions.filter { it.id in prioritySuggestionIds } @@ -126,6 +163,15 @@ class ContextualSuggestionsViewModel @Inject constructor( hideSuggestions() return } + lastInput = ResolvePageSuggestionsInput( + pageTypeSignals = pageTypeSignals, + url = url, + uiLocale = Locale.getDefault().toLanguageTag(), + ) + if (textSelectionCount > 0) { + if (textSelectionCount == 1) resolveTextSelectionSuggestions() else hideSuggestions() + return + } fetchSuggestions(url, pageTypeSignals) showSuggestions() } @@ -142,6 +188,7 @@ class ContextualSuggestionsViewModel @Inject constructor( url = url, uiLocale = Locale.getDefault().toLanguageTag(), ) + lastInput = input val resolved = suggestedPromptsProvider.resolveSuggestions(input) maxSuggestedPrompts = suggestedPromptsProvider.maxSuggestedPrompts() prioritySuggestionIds = suggestedPromptsProvider.prioritySuggestionIds() @@ -191,5 +238,6 @@ class ContextualSuggestionsViewModel @Inject constructor( companion object { private const val TIMEOUT_MS = 5_000L + private const val MAX_TEXT_SELECTION_SUGGESTIONS = 2 } } From a5de4479992394f75d7c66b203f7020918f20945 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 00:57:32 +0100 Subject: [PATCH 37/73] Fix tests --- .../nativeinput/RealNativeInputManagerTest.kt | 6 --- .../DuckChatContextualEntryViewModelTest.kt | 47 +++---------------- 2 files changed, 7 insertions(+), 46 deletions(-) diff --git a/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt index 6961fbb04811..5c20f8a54cd9 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt @@ -43,8 +43,6 @@ import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.DuckChatInputModeState import com.duckduckgo.duckchat.api.NativeInputEventListener import com.duckduckgo.duckchat.api.nativeinput.NativeInputState -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore import com.duckduckgo.duckchat.impl.ui.nativeinput.views.NativeInputWidget import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State @@ -85,8 +83,6 @@ class RealNativeInputManagerTest { private val nativeInputEventListener: NativeInputEventListener = mock() private val edgeToEdgeProvider: EdgeToEdgeProvider = mock() private val edgeToEdgeHandler = EdgeToEdgeHandler() - private val textSelectionStore = RealTextSelectionStore() - private val selectionPayloadBuilder: TextSelectionPayloadBuilder = mock() private val nativeInputStateBugKillSwitch = FakeFeatureToggleFactory.create(NativeInputStateBugKillSwitch::class.java) private val nativeInputUrlClearingFeature = FakeFeatureToggleFactory.create(NativeInputUrlClearingFeature::class.java) private val nativeInputOmnibarFeature = FakeFeatureToggleFactory.create(NativeInputOmnibarFeature::class.java) @@ -122,8 +118,6 @@ class RealNativeInputManagerTest { nativeInputEventListener, edgeToEdgeProvider, edgeToEdgeHandler, - textSelectionStore, - selectionPayloadBuilder, ) } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt index 688d48b15c27..7a62765bb15f 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt @@ -26,7 +26,6 @@ import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelecti import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionTarget import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -299,8 +298,7 @@ class DuckChatContextualEntryViewModelTest { @Test fun whenTextSelectionAttachedThenPageContextNotAttached() = runTest { viewModel.start("tab-1") - textSelectionStore.store("selected words", TextSelectionTarget.CONTEXTUAL) - textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + textSelectionStore.add("tab-1", "selected words") viewModel.onPageContextReceived(validContext) @@ -311,9 +309,8 @@ class DuckChatContextualEntryViewModelTest { fun whenPromptSubmittedWithTextSelectionsThenSelectionsSentOnOwnKeyAndCleared() = runTest { viewModel.start("tab-1") viewModel.onPageContextReceived(validContext) - textSelectionStore.store("first selection", TextSelectionTarget.CONTEXTUAL) - textSelectionStore.store("second selection", TextSelectionTarget.CONTEXTUAL) - textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + textSelectionStore.add("tab-1", "first selection") + textSelectionStore.add("tab-1", "second selection") viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) @@ -353,8 +350,7 @@ class DuckChatContextualEntryViewModelTest { fun whenSelectionExceedsMaxContentLengthThenTruncatedButSizeReported() = runTest { val long = "word ".repeat(3000) viewModel.start("tab-1") - textSelectionStore.store(long, TextSelectionTarget.CONTEXTUAL) - textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + textSelectionStore.add("tab-1", long) viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) @@ -374,17 +370,15 @@ class DuckChatContextualEntryViewModelTest { fun whenSelectionsExceedMaxThenExtrasDropped() { repeat( TextSelectionStore.MAX_SELECTIONS + 2, - ) { index -> textSelectionStore.store("selection $index", TextSelectionTarget.CONTEXTUAL) } - textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + ) { index -> textSelectionStore.add("tab-1", "selection $index") } assertEquals(TextSelectionStore.MAX_SELECTIONS, textSelectionStore.consume("tab-1").size) } @Test fun whenSelectionRemovedThenDroppedFromStore() = runTest { - textSelectionStore.store("keep me", TextSelectionTarget.CONTEXTUAL) - textSelectionStore.store("remove me", TextSelectionTarget.CONTEXTUAL) - textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL) + textSelectionStore.add("tab-1", "keep me") + textSelectionStore.add("tab-1", "remove me") val target = textSelectionStore.selections("tab-1").value.last() viewModel.start("tab-1") @@ -392,31 +386,4 @@ class DuckChatContextualEntryViewModelTest { assertEquals(listOf("keep me"), textSelectionStore.consume("tab-1").map { it.text }) } - - @Test - fun whenNoPendingSelectionThenClaimReportsNothing() { - assertFalse(textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL)) - } - - @Test - fun whenSelectionTargetsFullScreenThenOnlyAFullScreenSurfaceClaimsIt() { - textSelectionStore.store("from another app", TextSelectionTarget.FULL_SCREEN) - - // The browser tab that happens to resume first must not take it. - assertFalse(textSelectionStore.claim("tab-1", TextSelectionTarget.CONTEXTUAL)) - assertTrue(textSelectionStore.selections("tab-1").value.isEmpty()) - - assertTrue(textSelectionStore.claim("duck-ai-tab", TextSelectionTarget.FULL_SCREEN)) - assertEquals(listOf("from another app"), textSelectionStore.consume("duck-ai-tab").map { it.text }) - } - - @Test - fun whenTargetSwitchesThenEarlierPendingSelectionsDropped() { - textSelectionStore.store("in app", TextSelectionTarget.CONTEXTUAL) - - textSelectionStore.store("from another app", TextSelectionTarget.FULL_SCREEN) - textSelectionStore.claim("tab-1", TextSelectionTarget.FULL_SCREEN) - - assertEquals(listOf("from another app"), textSelectionStore.consume("tab-1").map { it.text }) - } } From 516f6893dfda5c57d67ace3f1e1877a44df41083 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 11:43:22 +0100 Subject: [PATCH 38/73] Disable submission for selections without a prompt --- .../duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt | 3 +-- .../com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt | 1 + .../duckchat/impl/ui/nativeinput/views/AttachmentView.kt | 2 +- .../impl/ui/nativeinput/views/NativeInputModeWidget.kt | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt index 4a0cea9d9ee0..c427ea1de4fd 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt @@ -43,8 +43,7 @@ interface NativeInputHost { fun showAttachmentChooser(showing: Boolean) fun showModelPicker(showing: Boolean) fun showReasoningPicker(showing: Boolean) - - fun attachmentChanged(hasAttachments: Boolean, limitExceeded: Boolean, supportsUpload: Boolean) + fun attachmentChanged(allowsBlankSubmit: Boolean, limitExceeded: Boolean, supportsUpload: Boolean) /** * Plugins call this whenever the user's tool selection changes. The widget routes this into diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index 599d0b200733..a87fe48b05f9 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -96,6 +96,7 @@ class AttachmentViewModel @Inject constructor( val supportedFileTypes: List = emptyList(), ) { val hasAttachments: Boolean get() = images.isNotEmpty() || files.isNotEmpty() || pageContext != null || textSelections.isNotEmpty() + val allowsBlankSubmit: Boolean get() = images.isNotEmpty() || files.isNotEmpty() || pageContext != null val acceptedMimeTypes: List get() { val types = mutableListOf() if (supportedFileTypes.isNotEmpty()) types.addAll(supportedFileTypes) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index 1eb485b5c069..84061b8f6b72 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -304,7 +304,7 @@ class AttachmentView( supportsUpload = state.supportsUpload updateButtonVisibility() host?.attachmentChanged( - hasAttachments = state.hasAttachments, + allowsBlankSubmit = state.allowsBlankSubmit, limitExceeded = !isEditMode && ( state.imageLimitError != null || state.fileLimitError != null || diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index 95f57515f1fb..e8e2531dcbb6 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -2135,13 +2135,13 @@ class NativeInputModeWidget @JvmOverloads constructor( } override fun attachmentChanged( - hasAttachments: Boolean, + allowsBlankSubmit: Boolean, limitExceeded: Boolean, supportsUpload: Boolean, ) { val hadLimitError = attachmentLimitExceeded attachmentLimitExceeded = limitExceeded - this.hasAttachments = hasAttachments + this.hasAttachments = allowsBlankSubmit if (hadLimitError != attachmentLimitExceeded && !isStreaming) { floatingSubmitContainer?.visibility = if (attachmentLimitExceeded) GONE else VISIBLE } From 3991bdd2a1c6e81730c6cd9dadaa7e475283b196 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 13:12:52 +0100 Subject: [PATCH 39/73] Fix launch source issue --- .../com/duckduckgo/app/browser/navigation/AppBrowserNav.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt index 63f65cea7ac6..131cc6263c89 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt @@ -59,9 +59,10 @@ class AppBrowserNav @Inject constructor() : BrowserNav { isContextual: Boolean, textSelection: String?, ): Intent { + val isExternal = textSelection != null && !isContextual return BrowserActivity.intent( context = context, - launchSource = if (isContextual) SelectedTextSearch else InAppNavigation, + launchSource = if (isExternal) SelectedTextSearch else InAppNavigation, openDuckChat = !isContextual, openDuckChatContextual = isContextual, duckChatUrl = duckChatUrl, From 17585482e764c9796685c3a7a32b91e725f8cd3d Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 13:29:03 +0100 Subject: [PATCH 40/73] Refactor --- .../duckduckgo/app/browser/BrowserActivity.kt | 18 +++++++++--------- .../app/browser/navigation/AppBrowserNav.kt | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 7eef1eb0994d..e0f3d8250c1f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -846,7 +846,12 @@ open class BrowserActivity : DuckDuckGoActivity() { } if (intent.getBooleanExtra(OPEN_DUCK_CHAT, false)) { - pendingDuckChatTextSelection = intent.getStringExtra(DUCK_CHAT_TEXT_SELECTION) + val textSelection = intent.getStringExtra(DUCK_CHAT_TEXT_SELECTION) + if (intent.getBooleanExtra(DUCK_CHAT_CONTEXTUAL, false)) { + currentTab?.launchContextualDuckAi(textSelection) + return + } + pendingDuckChatTextSelection = textSelection val sourceTabId = intent.getStringExtra(SOURCE_TAB_ID_EXTRA) pendingDuckChatForceImageGeneration = intent.getBooleanExtra(DUCK_CHAT_FORCE_IMAGE_GENERATION, false) intent.getStringExtra(DUCK_CHAT_ENTRY_POINT_EXTRA)?.let { source -> @@ -858,11 +863,6 @@ open class BrowserActivity : DuckDuckGoActivity() { return } - if (intent.getBooleanExtra(OPEN_DUCK_CHAT_CONTEXTUAL, false)) { - currentTab?.launchContextualDuckAi(intent.getStringExtra(DUCK_CHAT_TEXT_SELECTION)) - return - } - if (intent.getBooleanExtra(CLOSE_DUCK_CHAT, false)) { if (currentTab?.isInDuckAiMode() == true) { closeDuckChatFullScreen() @@ -1324,7 +1324,7 @@ open class BrowserActivity : DuckDuckGoActivity() { interstitialScreen: Boolean = false, openExistingTabId: String? = null, openDuckChat: Boolean = false, - openDuckChatContextual: Boolean = false, + duckChatContextual: Boolean = false, duckChatTextSelection: String? = null, closeDuckChat: Boolean = false, duckChatUrl: String? = null, @@ -1343,7 +1343,7 @@ open class BrowserActivity : DuckDuckGoActivity() { intent.putExtra(LAUNCH_FROM_INTERSTITIAL_EXTRA, interstitialScreen) intent.putExtra(OPEN_EXISTING_TAB_ID_EXTRA, openExistingTabId) intent.putExtra(OPEN_DUCK_CHAT, openDuckChat) - intent.putExtra(OPEN_DUCK_CHAT_CONTEXTUAL, openDuckChatContextual) + intent.putExtra(DUCK_CHAT_CONTEXTUAL, duckChatContextual) intent.putExtra(DUCK_CHAT_TEXT_SELECTION, duckChatTextSelection) intent.putExtra(DUCK_CHAT_ENTRY_POINT_EXTRA, launchSource.toDuckChatEntryPoint()?.name) intent.putExtra(CLOSE_DUCK_CHAT, closeDuckChat) @@ -1382,7 +1382,7 @@ open class BrowserActivity : DuckDuckGoActivity() { const val LAUNCH_SOURCE_PIXEL_VALUE = "LAUNCH_SOURCE_PIXEL_VALUE" private const val OPEN_DUCK_CHAT = "OPEN_DUCK_CHAT_EXTRA" - private const val OPEN_DUCK_CHAT_CONTEXTUAL = "OPEN_DUCK_CHAT_CONTEXTUAL_EXTRA" + private const val DUCK_CHAT_CONTEXTUAL = "DUCK_CHAT_CONTEXTUAL_EXTRA" private const val DUCK_CHAT_TEXT_SELECTION = "DUCK_CHAT_TEXT_SELECTION_EXTRA" private const val DUCK_CHAT_ENTRY_POINT_EXTRA = "DUCK_CHAT_ENTRY_POINT_EXTRA" private const val CLOSE_DUCK_CHAT = "CLOSE_DUCK_CHAT_EXTRA" diff --git a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt index 131cc6263c89..d4584bac14a6 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt @@ -63,8 +63,8 @@ class AppBrowserNav @Inject constructor() : BrowserNav { return BrowserActivity.intent( context = context, launchSource = if (isExternal) SelectedTextSearch else InAppNavigation, - openDuckChat = !isContextual, - openDuckChatContextual = isContextual, + openDuckChat = true, + duckChatContextual = isContextual, duckChatUrl = duckChatUrl, duckChatSessionActive = hasSessionActive, duckChatForceImageGeneration = forceImageGeneration, From 4975d7212c4546b77ab890c233182cc6991c7284 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 14:10:47 +0100 Subject: [PATCH 41/73] Refactor --- .../duckchat/impl/ui/AttachmentViewModel.kt | 28 +++++++++++++++++++ .../ui/nativeinput/views/AttachmentView.kt | 11 ++++++-- .../views/NativeInputModeWidget.kt | 28 ++++--------------- .../impl/ui/AttachmentViewModelTest.kt | 7 +++++ 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index a87fe48b05f9..8a79616caaed 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -46,6 +46,9 @@ import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedFile import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachmentProcessor +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -74,8 +77,13 @@ class AttachmentViewModel @Inject constructor( private val appBuildConfig: AppBuildConfig, nativeInputStateProvider: NativeInputStateProvider, private val duckChatPixels: DuckChatPixels, + private val textSelectionStore: TextSelectionStore, + private val textSelectionPayloadBuilder: TextSelectionPayloadBuilder, ) : ViewModel() { + private var textSelectionsTabId: String? = null + private var textSelectionsJob: Job? = null + enum class ImageSource(val pixelValue: String) { CAMERA("camera"), PHOTO_LIBRARY("photo_library"), @@ -354,6 +362,26 @@ class AttachmentViewModel @Inject constructor( _textSelections.value = selections } + fun bindTextSelections(tabId: String, textSelection: String?) { + textSelectionsTabId = tabId + textSelection?.let { textSelectionStore.add(tabId, it) } + textSelectionsJob?.cancel() + textSelectionsJob = viewModelScope.launch { + textSelectionStore.selections(tabId).collect { selections -> + _textSelections.value = selections.map { TextSelectionAttachment(id = it.id, text = it.text) } + } + } + } + + fun removeTextSelection(id: String) { + textSelectionsTabId?.let { textSelectionStore.remove(it, id) } + } + + fun getTextSelectionsJson(): JSONArray? { + val tabId = textSelectionsTabId ?: return null + return textSelectionPayloadBuilder.toJson(selections = textSelectionStore.consume(tabId), url = "") + } + fun setPageContext(attachment: PageContextAttachment) { _pageContextAttachment.value = attachment } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index 84061b8f6b72..762702ac43f7 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -132,6 +132,15 @@ class AttachmentView( fun getFileAttachmentsJson(): JSONArray? = viewModel?.getFileAttachmentsJson() + fun setTextSelections(selections: List) = viewModel?.setTextSelections(selections) + + fun getTextSelectionsJson(): JSONArray? = viewModel?.getTextSelectionsJson() + + fun bindTextSelections(tabId: String, textSelection: String?) { + viewModel?.bindTextSelections(tabId, textSelection) + onTextSelectionRemoved = { id -> viewModel?.removeTextSelection(id) } + } + fun clearAttachments() = viewModel?.clearAttachments() fun adoptAttachments( @@ -141,8 +150,6 @@ class AttachmentView( fun clearAttachmentsForNewChat() = viewModel?.clearAttachmentsForNewChat() - fun setTextSelections(selections: List) = viewModel?.setTextSelections(selections) - fun setPageContext(attachment: PageContextAttachment) = viewModel?.setPageContext(attachment) fun clearPageContext() = viewModel?.removePageContext() diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index e8e2531dcbb6..7b0a53050d71 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -91,8 +91,6 @@ import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.TextSelectionAttac import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.EditPromptScreenParams import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedFile import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore import com.duckduckgo.navigation.api.GlobalActivityStarter import com.google.android.material.card.MaterialCardView import com.google.android.material.shape.ShapeAppearanceModel @@ -270,12 +268,6 @@ class NativeInputModeWidget @JvmOverloads constructor( @Inject lateinit var pixel: Pixel - @Inject - lateinit var textSelectionStore: TextSelectionStore - - @Inject - lateinit var selectionPayloadBuilder: TextSelectionPayloadBuilder - @Inject lateinit var duckChatInternal: DuckChatInternal @@ -407,8 +399,7 @@ class NativeInputModeWidget @JvmOverloads constructor( private var pendingOnPageContextRemoved: (() -> Unit)? = null private var pendingPageContext: PageContextAttachment? = null private var pendingTextSelections: List = emptyList() - private var boundTextSelectionsTabId: String? = null - private var textSelectionsJob: Job? = null + private var pendingTextSelectionsTabId: String? = null private var pendingOnTextSelectionRemoved: ((String) -> Unit)? = null // adoptEditAttachments() can be called (from EditPromptActivity.onCreate) before the widget is @@ -810,6 +801,7 @@ class NativeInputModeWidget @JvmOverloads constructor( pluginView.bind(scope, viewModelFactory, nativeInputStateProvider, faviconManager) pendingPageContext?.let { pluginView.setPageContext(it) } pluginView.setTextSelections(pendingTextSelections) + pendingTextSelectionsTabId?.let { pluginView.bindTextSelections(it, textSelection = null) } if (hasPendingAdoptedAttachments(pendingAdoptedImages, pendingAdoptedFiles)) { pluginView.adoptAttachments(pendingAdoptedImages, pendingAdoptedFiles) } @@ -1678,21 +1670,11 @@ class NativeInputModeWidget @JvmOverloads constructor( } override fun bindTextSelections(tabId: String, textSelection: String?) { - boundTextSelectionsTabId = tabId - textSelection?.let { textSelectionStore.add(tabId, it) } - setTextSelectionRemovedAction { id -> textSelectionStore.remove(tabId, id) } - textSelectionsJob?.cancel() - textSelectionsJob = findViewTreeLifecycleOwner()?.lifecycleScope?.launch { - textSelectionStore.selections(tabId).collect { selections -> - setTextSelections(selections.map { TextSelectionAttachment(id = it.id, text = it.text) }) - } - } + pendingTextSelectionsTabId = tabId + attachmentView?.bindTextSelections(tabId, textSelection) } - override fun getTextSelectionsJson(): JSONArray? { - val tabId = boundTextSelectionsTabId ?: return null - return selectionPayloadBuilder.toJson(selections = textSelectionStore.consume(tabId), url = "") - } + override fun getTextSelectionsJson(): JSONArray? = attachmentView?.getTextSelectionsJson() override fun setContextualAttachmentActions( onAskAboutPage: () -> Unit, diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt index 28820418019c..401aa7015415 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt @@ -46,6 +46,8 @@ import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedFile import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachmentProcessor +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -124,6 +126,9 @@ class AttachmentViewModelTest { private lateinit var viewModel: AttachmentViewModel + private val textSelectionStore = RealTextSelectionStore() + private val textSelectionPayloadBuilder: TextSelectionPayloadBuilder = mock() + @Before fun setUp() { viewModel = AttachmentViewModel( @@ -136,6 +141,8 @@ class AttachmentViewModelTest { appBuildConfig = appBuildConfig, nativeInputStateProvider = nativeInputStateStore, duckChatPixels = duckChatPixels, + textSelectionStore = textSelectionStore, + textSelectionPayloadBuilder = textSelectionPayloadBuilder, ) } From 8d813347d6be7edc0955bb88b8b9459ae20602e4 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 14:38:34 +0100 Subject: [PATCH 42/73] Fix bug --- .../impl/ui/nativeinput/views/NativeInputModeWidget.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index 7b0a53050d71..de1e1d9bc343 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -400,6 +400,7 @@ class NativeInputModeWidget @JvmOverloads constructor( private var pendingPageContext: PageContextAttachment? = null private var pendingTextSelections: List = emptyList() private var pendingTextSelectionsTabId: String? = null + private var pendingTextSelection: String? = null private var pendingOnTextSelectionRemoved: ((String) -> Unit)? = null // adoptEditAttachments() can be called (from EditPromptActivity.onCreate) before the widget is @@ -801,7 +802,7 @@ class NativeInputModeWidget @JvmOverloads constructor( pluginView.bind(scope, viewModelFactory, nativeInputStateProvider, faviconManager) pendingPageContext?.let { pluginView.setPageContext(it) } pluginView.setTextSelections(pendingTextSelections) - pendingTextSelectionsTabId?.let { pluginView.bindTextSelections(it, textSelection = null) } + pendingTextSelectionsTabId?.let { bindTextSelections(it, pendingTextSelection) } if (hasPendingAdoptedAttachments(pendingAdoptedImages, pendingAdoptedFiles)) { pluginView.adoptAttachments(pendingAdoptedImages, pendingAdoptedFiles) } @@ -1671,7 +1672,11 @@ class NativeInputModeWidget @JvmOverloads constructor( override fun bindTextSelections(tabId: String, textSelection: String?) { pendingTextSelectionsTabId = tabId - attachmentView?.bindTextSelections(tabId, textSelection) + pendingTextSelection = textSelection + attachmentView?.let { view -> + view.bindTextSelections(tabId, textSelection) + pendingTextSelection = null + } } override fun getTextSelectionsJson(): JSONArray? = attachmentView?.getTextSelectionsJson() From d4b3be9e2d36b7bd0d50e54e8d064bf7a0d67c2b Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 14:49:19 +0100 Subject: [PATCH 43/73] Remove redundant setTextSelections --- .../contextual/ContextualNativeInputManager.kt | 6 +----- .../contextual/DuckChatContextualEntryDialog.kt | 8 ++------ .../DuckChatContextualEntryViewModel.kt | 9 ++------- .../duckchat/impl/ui/AttachmentViewModel.kt | 4 ---- .../impl/ui/nativeinput/views/AttachmentView.kt | 3 --- .../nativeinput/views/NativeInputModeWidget.kt | 17 ----------------- .../DuckChatContextualEntryViewModelTest.kt | 5 ++--- 7 files changed, 7 insertions(+), 45 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt index 759ec956f138..fa7575d76b9a 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt @@ -73,7 +73,6 @@ interface ContextualNativeInputManager { onPromptSubmitted: (NativeInputPrompt) -> Unit = {}, onAskAboutPage: () -> Unit = {}, onPageContextRemoved: () -> Unit = {}, - onTextSelectionRemoved: (String) -> Unit = {}, onVoiceChatRequested: () -> Unit = {}, onVoiceSearchRequested: () -> Unit = {}, ) @@ -129,7 +128,6 @@ class RealContextualNativeInputManager @Inject constructor( onPromptSubmitted: (NativeInputPrompt) -> Unit, onAskAboutPage: () -> Unit, onPageContextRemoved: () -> Unit, - onTextSelectionRemoved: (String) -> Unit, onVoiceChatRequested: () -> Unit, onVoiceSearchRequested: () -> Unit, ) { @@ -142,7 +140,7 @@ class RealContextualNativeInputManager @Inject constructor( tabId, widget, chatIdFlow, onSearchSubmitted, onCameraCaptureRequested, onFilePickerRequested, onPromptSubmitted, onAskAboutPage, onPageContextRemoved, - onTextSelectionRemoved, onVoiceChatRequested, onVoiceSearchRequested, + onVoiceChatRequested, onVoiceSearchRequested, ) observeNativeInputSetting(lifecycleOwner) observeVoiceChatEntry(widget, lifecycleOwner) @@ -217,7 +215,6 @@ class RealContextualNativeInputManager @Inject constructor( onPromptSubmitted: (NativeInputPrompt) -> Unit, onAskAboutPage: () -> Unit, onPageContextRemoved: () -> Unit, - onTextSelectionRemoved: (String) -> Unit, onVoiceChatRequested: () -> Unit, onVoiceSearchRequested: () -> Unit, ) { @@ -236,7 +233,6 @@ class RealContextualNativeInputManager @Inject constructor( onAskAboutPage = onAskAboutPage, onPageContextRemoved = onPageContextRemoved, ) - widget.setTextSelectionRemovedAction(onTextSelectionRemoved) widget.bindInputEvents( onSearchTextChanged = { }, onSearchSubmitted = { query -> diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt index 9eee3386f6e0..4ff04b1f881c 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt @@ -53,7 +53,6 @@ import com.duckduckgo.duckchat.impl.databinding.DialogContextualDuckAiEntryBindi import com.duckduckgo.duckchat.impl.feature.DuckChatFeature import com.duckduckgo.duckchat.impl.ui.filechooser.FileChooserIntentBuilder import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher -import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.TextSelectionAttachment import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.voice.api.VoiceSearchLauncher import com.duckduckgo.voice.api.VoiceSearchLauncher.Source.BROWSER @@ -224,10 +223,7 @@ class DuckChatContextualEntryDialog : DuckDuckGoBottomSheetDialogFragment() { } else { binding.entryNativeInputWidget.clearPageContext() } - binding.entryNativeInputWidget.setTextSelections( - state.textSelections.map { TextSelectionAttachment(id = it.id, text = it.text) }, - ) - binding.entrySuggestionsView.onTextSelectionCountChanged(state.textSelections.size) + binding.entrySuggestionsView.onTextSelectionCountChanged(state.textSelectionCount) updateQuickActionVisibility() } @@ -372,7 +368,6 @@ class DuckChatContextualEntryDialog : DuckDuckGoBottomSheetDialogFragment() { onFilePickerRequested = { callback, mimeTypes -> launchFilePicker(callback, mimeTypes) }, onAskAboutPage = { viewModel.onAttachContextRequested() }, onPageContextRemoved = { viewModel.onContextRemoved() }, - onTextSelectionRemoved = { id -> viewModel.onTextSelectionRemoved(id) }, onVoiceChatRequested = { duckChat.openVoiceDuckChat(DuckChatEntryPoint.VOICE) dismiss() @@ -383,6 +378,7 @@ class DuckChatContextualEntryDialog : DuckDuckGoBottomSheetDialogFragment() { }, ) contextualNativeInputManager.onInputMode() + binding.entryNativeInputWidget.bindTextSelections(tabId, textSelection = null) contextualNativeInputManager.onContextualReopened(tabId) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt index 59e07e02b7f7..503badefcc48 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt @@ -24,7 +24,6 @@ import com.duckduckgo.duckchat.impl.models.DuckAiModelManager import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelection import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST @@ -56,7 +55,7 @@ class DuckChatContextualEntryViewModel @Inject constructor( data class ViewState( val attachedContext: AttachedPageContext? = null, val latestPageContext: String? = null, - val textSelections: List = emptyList(), + val textSelectionCount: Int = 0, ) data class AttachedPageContext( @@ -88,14 +87,10 @@ class DuckChatContextualEntryViewModel @Inject constructor( this.tabId = tabId duckChatPixels.reportContextualFloatingInputShown() textSelectionStore.selections(tabId) - .onEach { selections -> _viewState.update { it.copy(textSelections = selections) } } + .onEach { selections -> _viewState.update { it.copy(textSelectionCount = selections.size) } } .launchIn(viewModelScope) } - fun onTextSelectionRemoved(id: String) { - textSelectionStore.remove(tabId, id) - } - fun onPageContextReceived(serializedPageContext: String) { if (!isContextValid(serializedPageContext)) return latestValidPageContext = serializedPageContext diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index 8a79616caaed..3251e80b30e7 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -358,10 +358,6 @@ class AttachmentViewModel @Inject constructor( val textSelections: List, ) - fun setTextSelections(selections: List) { - _textSelections.value = selections - } - fun bindTextSelections(tabId: String, textSelection: String?) { textSelectionsTabId = tabId textSelection?.let { textSelectionStore.add(tabId, it) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index 762702ac43f7..a4040b6ff440 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -46,7 +46,6 @@ import com.duckduckgo.duckchat.impl.nativeinput.NativeInputHost import com.duckduckgo.duckchat.impl.ui.AttachmentViewModel import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.ImageAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.PageContextAttachment -import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.TextSelectionAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedFile import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachment @@ -132,8 +131,6 @@ class AttachmentView( fun getFileAttachmentsJson(): JSONArray? = viewModel?.getFileAttachmentsJson() - fun setTextSelections(selections: List) = viewModel?.setTextSelections(selections) - fun getTextSelectionsJson(): JSONArray? = viewModel?.getTextSelectionsJson() fun bindTextSelections(tabId: String, textSelection: String?) { diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index de1e1d9bc343..23775a060871 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -87,7 +87,6 @@ import com.duckduckgo.duckchat.impl.pixel.inputScreenPixelsModeParam import com.duckduckgo.duckchat.impl.store.DefaultTogglePosition import com.duckduckgo.duckchat.impl.ui.NativeInputModeWidgetViewModel import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.PageContextAttachment -import com.duckduckgo.duckchat.impl.ui.nativeinput.attachment.TextSelectionAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.EditPromptScreenParams import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedFile import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage @@ -194,8 +193,6 @@ interface NativeInputWidget { fun setWidgetPosition(isBottom: Boolean) fun setWidgetRootView(view: View) - fun setTextSelections(selections: List) - fun setTextSelectionRemovedAction(onTextSelectionRemoved: (String) -> Unit) fun bindTextSelections(tabId: String, textSelection: String?) fun getTextSelectionsJson(): JSONArray? @@ -398,10 +395,8 @@ class NativeInputModeWidget @JvmOverloads constructor( private var pendingAskAboutPage: (() -> Unit)? = null private var pendingOnPageContextRemoved: (() -> Unit)? = null private var pendingPageContext: PageContextAttachment? = null - private var pendingTextSelections: List = emptyList() private var pendingTextSelectionsTabId: String? = null private var pendingTextSelection: String? = null - private var pendingOnTextSelectionRemoved: ((String) -> Unit)? = null // adoptEditAttachments() can be called (from EditPromptActivity.onCreate) before the widget is // attached and the AttachmentView plugin exists, so the values are held here and applied once @@ -798,10 +793,8 @@ class NativeInputModeWidget @JvmOverloads constructor( pluginView.isEditMode = isEditWidget pluginView.onAskAboutPage = pendingAskAboutPage pluginView.onPageContextRemoved = pendingOnPageContextRemoved - pluginView.onTextSelectionRemoved = pendingOnTextSelectionRemoved pluginView.bind(scope, viewModelFactory, nativeInputStateProvider, faviconManager) pendingPageContext?.let { pluginView.setPageContext(it) } - pluginView.setTextSelections(pendingTextSelections) pendingTextSelectionsTabId?.let { bindTextSelections(it, pendingTextSelection) } if (hasPendingAdoptedAttachments(pendingAdoptedImages, pendingAdoptedFiles)) { pluginView.adoptAttachments(pendingAdoptedImages, pendingAdoptedFiles) @@ -1660,16 +1653,6 @@ class NativeInputModeWidget @JvmOverloads constructor( override fun getPageContext(): PageContextAttachment? = attachmentView?.getPageContext() - override fun setTextSelections(selections: List) { - pendingTextSelections = selections - attachmentView?.setTextSelections(selections) - } - - override fun setTextSelectionRemovedAction(onTextSelectionRemoved: (String) -> Unit) { - pendingOnTextSelectionRemoved = onTextSelectionRemoved - attachmentView?.onTextSelectionRemoved = onTextSelectionRemoved - } - override fun bindTextSelections(tabId: String, textSelection: String?) { pendingTextSelectionsTabId = tabId pendingTextSelection = textSelection diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt index 7a62765bb15f..a32555c83ca9 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt @@ -376,13 +376,12 @@ class DuckChatContextualEntryViewModelTest { } @Test - fun whenSelectionRemovedThenDroppedFromStore() = runTest { + fun whenSelectionRemovedThenDroppedFromStore() { textSelectionStore.add("tab-1", "keep me") textSelectionStore.add("tab-1", "remove me") val target = textSelectionStore.selections("tab-1").value.last() - viewModel.start("tab-1") - viewModel.onTextSelectionRemoved(target.id) + textSelectionStore.remove("tab-1", target.id) assertEquals(listOf("keep me"), textSelectionStore.consume("tab-1").map { it.text }) } From 519d8da7e09d3da5a4508c999fa7d2212a6633b2 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 14:56:15 +0100 Subject: [PATCH 44/73] Rename variable --- .../duckchat/impl/nativeinput/NativeInputPlugin.kt | 2 +- .../duckchat/impl/ui/AttachmentViewModel.kt | 2 +- .../impl/ui/nativeinput/views/AttachmentView.kt | 2 +- .../ui/nativeinput/views/NativeInputModeWidget.kt | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt index c427ea1de4fd..b19f58600fdb 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/nativeinput/NativeInputPlugin.kt @@ -43,7 +43,7 @@ interface NativeInputHost { fun showAttachmentChooser(showing: Boolean) fun showModelPicker(showing: Boolean) fun showReasoningPicker(showing: Boolean) - fun attachmentChanged(allowsBlankSubmit: Boolean, limitExceeded: Boolean, supportsUpload: Boolean) + fun attachmentChanged(hasStandaloneAttachments: Boolean, limitExceeded: Boolean, supportsUpload: Boolean) /** * Plugins call this whenever the user's tool selection changes. The widget routes this into diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index 3251e80b30e7..ec007afb2d51 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -104,7 +104,7 @@ class AttachmentViewModel @Inject constructor( val supportedFileTypes: List = emptyList(), ) { val hasAttachments: Boolean get() = images.isNotEmpty() || files.isNotEmpty() || pageContext != null || textSelections.isNotEmpty() - val allowsBlankSubmit: Boolean get() = images.isNotEmpty() || files.isNotEmpty() || pageContext != null + val hasStandaloneAttachments: Boolean get() = images.isNotEmpty() || files.isNotEmpty() || pageContext != null val acceptedMimeTypes: List get() { val types = mutableListOf() if (supportedFileTypes.isNotEmpty()) types.addAll(supportedFileTypes) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index a4040b6ff440..053ed7061ada 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -308,7 +308,7 @@ class AttachmentView( supportsUpload = state.supportsUpload updateButtonVisibility() host?.attachmentChanged( - allowsBlankSubmit = state.allowsBlankSubmit, + hasStandaloneAttachments = state.hasStandaloneAttachments, limitExceeded = !isEditMode && ( state.imageLimitError != null || state.fileLimitError != null || diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index 23775a060871..c46d57b93a52 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -332,7 +332,7 @@ class NativeInputModeWidget @JvmOverloads constructor( private var chatSuggestionsUserEnabled: Boolean = true private var isStreaming: Boolean = false private var attachmentLimitExceeded: Boolean = false - private var hasAttachments: Boolean = false + private var hasStandaloneAttachments: Boolean = false // Set by the manager; the toggle-row back arrow is the inverse of this (fills in while the nav bar is hidden). private var navBarVisible: Boolean = false @@ -1096,7 +1096,7 @@ class NativeInputModeWidget @JvmOverloads constructor( } private fun updateVoiceButtonVisibility() { - val isBlank = inputField.text.isNullOrBlank() && !hasAttachments + val isBlank = inputField.text.isNullOrBlank() && !hasStandaloneAttachments setVoiceButtonVisible(!isEditWidget && voiceSearchAvailable && isBlank) val host = voiceHostButtons() host?.setVoiceSearchVisible(false) @@ -1104,7 +1104,7 @@ class NativeInputModeWidget @JvmOverloads constructor( } private fun updateSendButtonVisibility() { - val hasContent = isStreaming || inputField.text.isNotBlank() || hasAttachments + val hasContent = isStreaming || inputField.text.isNotBlank() || hasStandaloneAttachments val visible = isChatTabSelected() && hasContent submitButtons?.setSendButtonVisible(visible) if (!isStreaming) { @@ -1370,7 +1370,7 @@ class NativeInputModeWidget @JvmOverloads constructor( } // Capture text presence before any clearFocus / submission mutates the field. val hasText = !(message ?: inputField.text?.toString()).isNullOrBlank() - if (message == null && inputField.text.isNullOrBlank() && hasAttachments && isChatTabSelected()) { + if (message == null && inputField.text.isNullOrBlank() && hasStandaloneAttachments && isChatTabSelected()) { fireSubmissionPixels(hasText = hasText) onChatSent?.invoke("") inputField.clearFocus() @@ -2105,13 +2105,13 @@ class NativeInputModeWidget @JvmOverloads constructor( } override fun attachmentChanged( - allowsBlankSubmit: Boolean, + hasStandaloneAttachments: Boolean, limitExceeded: Boolean, supportsUpload: Boolean, ) { val hadLimitError = attachmentLimitExceeded attachmentLimitExceeded = limitExceeded - this.hasAttachments = allowsBlankSubmit + this.hasStandaloneAttachments = hasStandaloneAttachments if (hadLimitError != attachmentLimitExceeded && !isStreaming) { floatingSubmitContainer?.visibility = if (attachmentLimitExceeded) GONE else VISIBLE } From e27da5d2e3cdba01e3063e63c4d19c8245c791c1 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 15:00:20 +0100 Subject: [PATCH 45/73] Add missing tests --- .../duckchat/impl/RealDuckChatTest.kt | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) 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 7c39516b31a0..12ab3592f12a 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 @@ -79,6 +79,7 @@ import org.junit.runner.RunWith import org.mockito.Mockito.mock import org.mockito.Mockito.spy import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq @@ -163,7 +164,7 @@ class RealDuckChatTest { ) coroutineRule.testScope.advanceUntilIdle() - whenever(mockBrowserNav.openDuckChat(any(), any(), any(), any())).thenReturn(mockIntent) + whenever(mockBrowserNav.openDuckChat(any(), any(), any(), any(), any(), anyOrNull())).thenReturn(mockIntent) whenever(mockBrowserNav.closeDuckChat(any())).thenReturn(mockIntent) } @@ -1564,6 +1565,46 @@ class RealDuckChatTest { assertFalse(testee.showContextualMode.value) } + @Test + fun `when contextual mode, redesign and text selection action enabled, then showTextSelectionAction emits true`() = runTest { + duckChatFeature.contextualMode().setRawStoredState(State(enable = true)) + duckChatFeature.contextualSheetRedesign().setRawStoredState(State(enable = true)) + duckChatFeature.duckAiTextSelectionAction().setRawStoredState(State(enable = true)) + testee.onPrivacyConfigDownloaded() + + assertTrue(testee.showTextSelectionAction.value) + } + + @Test + fun `when text selection action disabled, then showTextSelectionAction emits false`() = runTest { + duckChatFeature.contextualMode().setRawStoredState(State(enable = true)) + duckChatFeature.contextualSheetRedesign().setRawStoredState(State(enable = true)) + duckChatFeature.duckAiTextSelectionAction().setRawStoredState(State(enable = false)) + testee.onPrivacyConfigDownloaded() + + assertFalse(testee.showTextSelectionAction.value) + } + + @Test + fun `when contextual sheet redesign disabled, then showTextSelectionAction emits false`() = runTest { + duckChatFeature.contextualMode().setRawStoredState(State(enable = true)) + duckChatFeature.contextualSheetRedesign().setRawStoredState(State(enable = false)) + duckChatFeature.duckAiTextSelectionAction().setRawStoredState(State(enable = true)) + testee.onPrivacyConfigDownloaded() + + assertFalse(testee.showTextSelectionAction.value) + } + + @Test + fun `when contextual mode disabled, then showTextSelectionAction emits false`() = runTest { + duckChatFeature.contextualMode().setRawStoredState(State(enable = false)) + duckChatFeature.contextualSheetRedesign().setRawStoredState(State(enable = true)) + duckChatFeature.duckAiTextSelectionAction().setRawStoredState(State(enable = true)) + testee.onPrivacyConfigDownloaded() + + assertFalse(testee.showTextSelectionAction.value) + } + @Test fun `when contextual mode enabled, isDuckChatContextualModeEnabled returns true`() = runTest { duckChatFeature.contextualMode().setRawStoredState(State(enable = true)) From d02c1687eb0499e8e0781c6db154e59e24e76927 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 15:12:25 +0100 Subject: [PATCH 46/73] Add missing tests --- .../impl/ui/AttachmentViewModelTest.kt | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt index 401aa7015415..f24dd8c86fc1 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt @@ -284,6 +284,76 @@ class AttachmentViewModelTest { assertTrue(viewModel.attachmentState.value.hasAttachments) } + @Test + fun whenOnlyTextSelectionsAttachedThenHasNoStandaloneAttachments() = runTest { + viewModel.bindTextSelections("tab-1", "selected words") + + assertTrue(viewModel.attachmentState.value.hasAttachments) + assertFalse(viewModel.attachmentState.value.hasStandaloneAttachments) + } + + @Test + fun whenImagesAddedThenHasStandaloneAttachments() = runTest { + addImages(1) + + assertTrue(viewModel.attachmentState.value.hasStandaloneAttachments) + } + + @Test + fun whenFilesAddedThenHasStandaloneAttachments() = runTest { + addFiles(aFileAttachment()) + + assertTrue(viewModel.attachmentState.value.hasStandaloneAttachments) + } + + @Test + fun whenBoundWithTextSelectionThenItIsAttachedToThatTab() = runTest { + viewModel.bindTextSelections("tab-1", "selected words") + + assertEquals(listOf("selected words"), viewModel.attachmentState.value.textSelections.map { it.text }) + } + + @Test + fun whenBoundWithoutTextSelectionThenNothingIsAttached() = runTest { + viewModel.bindTextSelections("tab-1", textSelection = null) + + assertTrue(viewModel.attachmentState.value.textSelections.isEmpty()) + } + + @Test + fun whenBoundToADifferentTabThenOtherTabsSelectionsAreNotShown() = runTest { + textSelectionStore.add("tab-other", "not mine") + + viewModel.bindTextSelections("tab-1", textSelection = null) + + assertTrue(viewModel.attachmentState.value.textSelections.isEmpty()) + } + + @Test + fun whenTextSelectionRemovedThenItIsDroppedFromState() = runTest { + viewModel.bindTextSelections("tab-1", "keep me") + viewModel.bindTextSelections("tab-1", "remove me") + val idToRemove = viewModel.attachmentState.value.textSelections.last().id + + viewModel.removeTextSelection(idToRemove) + + assertEquals(listOf("keep me"), viewModel.attachmentState.value.textSelections.map { it.text }) + } + + @Test + fun whenTextSelectionsConsumedThenTheyAreClearedFromState() = runTest { + viewModel.bindTextSelections("tab-1", "selected words") + + viewModel.getTextSelectionsJson() + + assertTrue(viewModel.attachmentState.value.textSelections.isEmpty()) + } + + @Test + fun whenNotBoundThenTextSelectionsJsonIsNull() = runTest { + assertNull(viewModel.getTextSelectionsJson()) + } + @Test fun whenImageRemovedByIdThenItIsNoLongerInState() = runTest { addImages(2) From 3cece73d8bdf7a19465700bf9dcea3a34c46eedf Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 15:18:39 +0100 Subject: [PATCH 47/73] Refactor --- .../ContextualSuggestionsViewModel.kt | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt index 6a30ec475bab..91d13562f5c7 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt @@ -110,20 +110,24 @@ class ContextualSuggestionsViewModel @Inject constructor( if (textSelectionCount == count) return textSelectionCount = count loadJob?.cancel() - loadJob = viewModelScope.launch { - if (!suggestionsEnabled()) { - hideSuggestions() - return@launch - } - when (count) { - 0 -> { - fetchSuggestions(lastInput?.url, lastInput?.pageTypeSignals) - showSuggestions() - } - 1 -> resolveTextSelectionSuggestions() - else -> hideSuggestions() - } + loadJob = viewModelScope.launch { loadSuggestions(count) } + } + + private suspend fun loadSuggestions(textSelectionCount: Int) { + if (!suggestionsEnabled()) { + hideSuggestions() + return } + when (textSelectionCount) { + 0 -> resolvePageSuggestions() + 1 -> resolveTextSelectionSuggestions() + else -> hideSuggestions() + } + } + + private suspend fun resolvePageSuggestions() { + fetchSuggestions(lastInput?.url, lastInput?.pageTypeSignals) + showSuggestions() } private suspend fun resolveTextSelectionSuggestions() { From cb7883001b967e9f9e71bd7c7ecc71a6cb4d2609 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 17:11:05 +0100 Subject: [PATCH 48/73] Update chip display logic --- .../TextSelectionAttachmentsContainerView.kt | 19 ++++++++++++++++++- .../src/main/res/values/donottranslate.xml | 6 ++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt index d034c8204b04..9604f04880d5 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt @@ -42,16 +42,33 @@ class TextSelectionAttachmentsContainerView @JvmOverloads constructor( fun current(): List = attachments + private fun displayTitle(text: String): String { + val words = text.split(WHITESPACE).filter { it.isNotEmpty() } + val collapsed = words.joinToString(" ") + val snippet = if (collapsed.length > MAX_DISPLAY_TITLE_LENGTH) { + collapsed.take(MAX_DISPLAY_TITLE_LENGTH).trimEnd() + "…" + } else { + collapsed + } + val wordCount = resources.getQuantityString(R.plurals.duckAiTextSelectionWordCount, words.size, words.size) + return "$wordCount · $snippet" + } + fun render(attachments: List) { this.attachments = attachments removeAllViews() attachments.forEach { attachment -> val itemView = LayoutInflater.from(context).inflate(R.layout.view_page_context_attachment_item, this, false) - itemView.findViewById(R.id.pageContextTitle).text = attachment.text + itemView.findViewById(R.id.pageContextTitle).text = displayTitle(attachment.text) itemView.findViewById(R.id.pageContextRemove).setOnClickListener { onAttachmentRemoved?.invoke(attachment.id) } addView(itemView) } } + + private companion object { + private const val MAX_DISPLAY_TITLE_LENGTH = 120 + private val WHITESPACE = Regex("\\s+") + } } diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index e0878b689d77..841f810a6a25 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -58,4 +58,10 @@ Ask Duck.ai + + + + %1$d word + %1$d words + From 809b907018f3179d2817b6c32e4cc52cad7f36a6 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 17:24:39 +0100 Subject: [PATCH 49/73] Use correct icon --- .../TextSelectionAttachmentsContainerView.kt | 6 +- .../main/res/drawable/ic_text_select_16.xml | 25 ++++++++ .../view_text_selection_attachment_item.xml | 63 +++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 duckchat/duckchat-impl/src/main/res/drawable/ic_text_select_16.xml create mode 100644 duckchat/duckchat-impl/src/main/res/layout/view_text_selection_attachment_item.xml diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt index 9604f04880d5..a92136939c66 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.kt @@ -58,9 +58,9 @@ class TextSelectionAttachmentsContainerView @JvmOverloads constructor( this.attachments = attachments removeAllViews() attachments.forEach { attachment -> - val itemView = LayoutInflater.from(context).inflate(R.layout.view_page_context_attachment_item, this, false) - itemView.findViewById(R.id.pageContextTitle).text = displayTitle(attachment.text) - itemView.findViewById(R.id.pageContextRemove).setOnClickListener { + val itemView = LayoutInflater.from(context).inflate(R.layout.view_text_selection_attachment_item, this, false) + itemView.findViewById(R.id.textSelectionTitle).text = displayTitle(attachment.text) + itemView.findViewById(R.id.textSelectionRemove).setOnClickListener { onAttachmentRemoved?.invoke(attachment.id) } addView(itemView) diff --git a/duckchat/duckchat-impl/src/main/res/drawable/ic_text_select_16.xml b/duckchat/duckchat-impl/src/main/res/drawable/ic_text_select_16.xml new file mode 100644 index 000000000000..062a66c15bdf --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/drawable/ic_text_select_16.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/duckchat/duckchat-impl/src/main/res/layout/view_text_selection_attachment_item.xml b/duckchat/duckchat-impl/src/main/res/layout/view_text_selection_attachment_item.xml new file mode 100644 index 000000000000..bd962982a2d5 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/view_text_selection_attachment_item.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + From b82d3e108341fc2ce90a2ea16a1c0e43ec8341a3 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 22:20:15 +0100 Subject: [PATCH 50/73] Scroll to end when adding a new selection --- .../duckchat/impl/ui/nativeinput/views/AttachmentView.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index 053ed7061ada..6fbca6d3c3f1 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -82,6 +82,7 @@ class AttachmentView( private var fileAttachmentsContainer: FileAttachmentsContainerView? = null private var pageContextContainer: PageContextAttachmentView? = null private var textSelectionsContainer: TextSelectionAttachmentsContainerView? = null + private var attachmentsScroll: HorizontalScrollView? = null private var limitErrorView: TextView? = null init { @@ -190,6 +191,7 @@ class AttachmentView( isHorizontalScrollBarEnabled = false } parent.addView(scroll) + attachmentsScroll = scroll val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL @@ -277,7 +279,9 @@ class AttachmentView( private fun syncTextSelections(state: AttachmentViewModel.AttachmentState) { val view = textSelectionsContainer ?: return if (view.current() != state.textSelections) { + val isNewAttachment = state.textSelections.size > view.current().size view.render(state.textSelections) + if (isNewAttachment) attachmentsScroll?.post { attachmentsScroll?.fullScroll(FOCUS_RIGHT) } } } From 3faae8431035217f7b113ee2991f8a465d14d04f Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 22:32:26 +0100 Subject: [PATCH 51/73] Fix bug where selection is not attached in current contextual chat --- .../ContextualNativeInputManager.kt | 1 + .../DuckChatContextualEntryDialog.kt | 1 - .../impl/contextual/RealDuckChatContextual.kt | 20 +++++++++---------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt index fa7575d76b9a..bc1f88edd8ef 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt @@ -219,6 +219,7 @@ class RealContextualNativeInputManager @Inject constructor( onVoiceSearchRequested: () -> Unit, ) { widget.configureContextual(tabId) + widget.bindTextSelections(tabId, textSelection = null) widget.bindChatIdSource(chatIdFlow) widget.bindModelPickerEnabledSource(modelPickerEnabled) widget.hideMainButtons() diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt index 4ff04b1f881c..900a0d80a8f2 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt @@ -378,7 +378,6 @@ class DuckChatContextualEntryDialog : DuckDuckGoBottomSheetDialogFragment() { }, ) contextualNativeInputManager.onInputMode() - binding.entryNativeInputWidget.bindTextSelections(tabId, textSelection = null) contextualNativeInputManager.onContextualReopened(tabId) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt index 73ccb44f002c..9f277a9b1f4d 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt @@ -67,6 +67,11 @@ class RealDuckChatContextual @Inject constructor( return } textSelection?.let { textSelectionStore.add(sourceTabId, it) } + if (hasChatInProgress(sourceTabId)) { + // The sheet would reopen the existing chat for this tab, so skip the entry menu and open it directly. + showChatSurface() + return + } if (textSelectionStore.selections(sourceTabId).value.isNotEmpty()) { showEntryDialog(anchor, sourceTabId, showChatSurface) return @@ -77,16 +82,11 @@ class RealDuckChatContextual @Inject constructor( showChatSurface() return } - if (hasChatInProgress(sourceTabId)) { - // The sheet would reopen the existing chat for this tab, so skip the entry menu and open it directly. - showChatSurface() - } else { - val serpQuery = sourceUrl - ?.takeIf { duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(it) } - ?.let { duckDuckGoUrlDetector.extractQuery(it) } - ?.takeIf { it.isNotBlank() } - showMenu(sourceTabId, anchor, sourceUrl, serpQuery, showChatSurface) - } + val serpQuery = sourceUrl + ?.takeIf { duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(it) } + ?.let { duckDuckGoUrlDetector.extractQuery(it) } + ?.takeIf { it.isNotBlank() } + showMenu(sourceTabId, anchor, sourceUrl, serpQuery, showChatSurface) } private suspend fun hasChatInProgress(tabId: String): Boolean { From c742e5b185324c88b387b42084d106826465de8b Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 22:41:45 +0100 Subject: [PATCH 52/73] Add additional check for isContextualNativeInputEnabled --- .../com/duckduckgo/duckchat/impl/RealDuckChat.kt | 6 +++++- .../duckduckgo/duckchat/impl/RealDuckChatTest.kt | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 a027089c412c..3c8cf88eeedd 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 @@ -1158,7 +1158,11 @@ class RealDuckChat @Inject constructor( contextualMenuAllChatsEnabled = contextualSheetRedesignEnabled && duckChatFeature.contextualMenuAllChats().isEnabled() - _showTextSelectionAction.emit(contextualSheetRedesignEnabled && duckChatFeature.duckAiTextSelectionAction().isEnabled()) + _showTextSelectionAction.emit( + contextualSheetRedesignEnabled && + isContextualNativeInputEnabled && + duckChatFeature.duckAiTextSelectionAction().isEnabled(), + ) isAutomaticContextAttachmentEnabled = isContextualModeEnabled && duckChatFeature.automaticContextAttachment() 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 12ab3592f12a..a5739f461404 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 @@ -1570,6 +1570,9 @@ class RealDuckChatTest { duckChatFeature.contextualMode().setRawStoredState(State(enable = true)) duckChatFeature.contextualSheetRedesign().setRawStoredState(State(enable = true)) duckChatFeature.duckAiTextSelectionAction().setRawStoredState(State(enable = true)) + duckChatFeature.nativeInputField().setRawStoredState(State(enable = true)) + duckChatFeature.nativeChatInput().setRawStoredState(State(enable = true)) + duckChatFeature.contextualNativeInput().setRawStoredState(State(enable = true)) testee.onPrivacyConfigDownloaded() assertTrue(testee.showTextSelectionAction.value) @@ -1595,6 +1598,17 @@ class RealDuckChatTest { assertFalse(testee.showTextSelectionAction.value) } + @Test + fun `when contextual native input disabled, then showTextSelectionAction emits false`() = runTest { + duckChatFeature.contextualMode().setRawStoredState(State(enable = true)) + duckChatFeature.contextualSheetRedesign().setRawStoredState(State(enable = true)) + duckChatFeature.duckAiTextSelectionAction().setRawStoredState(State(enable = true)) + duckChatFeature.contextualNativeInput().setRawStoredState(State(enable = false)) + testee.onPrivacyConfigDownloaded() + + assertFalse(testee.showTextSelectionAction.value) + } + @Test fun `when contextual mode disabled, then showTextSelectionAction emits false`() = runTest { duckChatFeature.contextualMode().setRawStoredState(State(enable = false)) From 22748e13c03bd727de307aef0fd34af0c39c9e88 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 22:51:43 +0100 Subject: [PATCH 53/73] Update attachment logic --- .../ContextualSuggestionsViewModel.kt | 11 +++------ .../textselection/TextSelectionStore.kt | 23 +++++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt index 91d13562f5c7..eba9cd98a6e9 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt @@ -118,11 +118,7 @@ class ContextualSuggestionsViewModel @Inject constructor( hideSuggestions() return } - when (textSelectionCount) { - 0 -> resolvePageSuggestions() - 1 -> resolveTextSelectionSuggestions() - else -> hideSuggestions() - } + if (textSelectionCount > 0) resolveTextSelectionSuggestions() else resolvePageSuggestions() } private suspend fun resolvePageSuggestions() { @@ -149,8 +145,7 @@ class ContextualSuggestionsViewModel @Inject constructor( } private fun visibleSuggestions(): List { - if (textSelectionCount == 1) return resolvedSuggestions.take(MAX_TEXT_SELECTION_SUGGESTIONS) - if (textSelectionCount > 1) return emptyList() + if (textSelectionCount > 0) return resolvedSuggestions.take(MAX_TEXT_SELECTION_SUGGESTIONS) val capacity = (maxSuggestedPrompts - reservedQuickActionSlots).coerceAtLeast(0) if (resolvedSuggestions.size <= capacity) return resolvedSuggestions val prioritySuggestions = resolvedSuggestions.filter { it.id in prioritySuggestionIds } @@ -173,7 +168,7 @@ class ContextualSuggestionsViewModel @Inject constructor( uiLocale = Locale.getDefault().toLanguageTag(), ) if (textSelectionCount > 0) { - if (textSelectionCount == 1) resolveTextSelectionSuggestions() else hideSuggestions() + resolveTextSelectionSuggestions() return } fetchSuggestions(url, pageTypeSignals) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt index 012158957dca..f1e072b2d7f8 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import java.util.UUID import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @@ -34,7 +35,7 @@ data class TextSelection( interface TextSelectionStore { fun selections(tabId: String): StateFlow> - fun add(tabId: String, text: String) + fun add(tabId: String, text: String): Boolean fun consume(tabId: String): List fun remove(tabId: String, id: String) @@ -49,23 +50,27 @@ class RealTextSelectionStore @Inject constructor() : TextSelectionStore { private val selections = ConcurrentHashMap>>() - override fun selections(tabId: String): StateFlow> = flowFor(tabId) + override fun selections(tabId: String): StateFlow> = getFlow(tabId) - override fun add(tabId: String, text: String) { - val selection = selectionOf(text) ?: return - flowFor(tabId).update { (it + selection).take(TextSelectionStore.MAX_SELECTIONS) } + override fun add(tabId: String, text: String): Boolean { + val selection = getTextSelection(text) ?: return false + val selections = getFlow(tabId).updateAndGet { existing -> + val isDuplicate = existing.any { it.text == selection.text } + if (isDuplicate || existing.size >= TextSelectionStore.MAX_SELECTIONS) existing else existing + selection + } + return selections.any { it.text == selection.text } } - override fun consume(tabId: String): List = flowFor(tabId).getAndUpdate { emptyList() } + override fun consume(tabId: String): List = getFlow(tabId).getAndUpdate { emptyList() } override fun remove(tabId: String, id: String) { - flowFor(tabId).update { current -> current.filterNot { it.id == id } } + getFlow(tabId).update { current -> current.filterNot { it.id == id } } } - private fun flowFor(tabId: String): MutableStateFlow> = + private fun getFlow(tabId: String): MutableStateFlow> = selections.computeIfAbsent(tabId) { MutableStateFlow(emptyList()) } - private fun selectionOf(text: String): TextSelection? { + private fun getTextSelection(text: String): TextSelection? { val trimmed = text.trim() if (trimmed.isEmpty()) return null return TextSelection(UUID.randomUUID().toString(), trimmed) From fb7ca6885b0d7ac0fe140410c755ea313b7de743 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 22:59:58 +0100 Subject: [PATCH 54/73] Fix submission bug --- .../impl/contextual/ContextualNativeInputManager.kt | 4 +++- .../impl/contextual/DuckChatContextualWebViewFragment.kt | 1 + .../contextual/DuckChatContextualWebViewViewModel.kt | 3 ++- .../suggestions/ContextualSuggestionsViewModelTest.kt | 9 +++++---- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt index bc1f88edd8ef..f16d60dfc85b 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt @@ -55,6 +55,7 @@ data class NativeInputPrompt( val selectedTool: String?, val imagesJson: JSONArray?, val filesJson: JSONArray?, + val selectionsJson: JSONArray? = null, ) interface ContextualNativeInputManager { @@ -242,6 +243,7 @@ class RealContextualNativeInputManager @Inject constructor( onChatSubmitted = { prompt -> val imagesJson = widget.getImageAttachmentsJson() val filesJson = widget.getFileAttachmentsJson() + val selectionsJson = widget.getTextSelectionsJson() val modelId = widget.getSelectedModelId() val reasoningEffort = widget.getResolvedReasoningEffort() val selectedTool = widget.getSelectedTool() @@ -253,7 +255,7 @@ class RealContextualNativeInputManager @Inject constructor( // new chat from INPUT and appends to the active chat from WEBVIEW — the web page decides // which, based on its own state, not on the native caller. onPromptSubmitted( - NativeInputPrompt(prompt, modelId, reasoningEffort, selectedTool, imagesJson, filesJson), + NativeInputPrompt(prompt, modelId, reasoningEffort, selectedTool, imagesJson, filesJson, selectionsJson), ) widget.clearSelectedTool() widget.text = "" diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewFragment.kt index 0d3e94a55810..d176b7729516 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewFragment.kt @@ -476,6 +476,7 @@ class DuckChatContextualWebViewFragment : selectedTool = submitted.selectedTool, imagesJson = submitted.imagesJson, filesJson = submitted.filesJson, + selectionsJson = submitted.selectionsJson, ) }, onAskAboutPage = { viewModel.onAskAboutPageClicked() }, diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModel.kt index d54525710da0..880e96647231 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModel.kt @@ -362,9 +362,10 @@ class DuckChatContextualWebViewViewModel @Inject constructor( selectedTool: String? = null, imagesJson: JSONArray? = null, filesJson: JSONArray? = null, + selectionsJson: JSONArray? = null, ) { val attachedContext = pageContextState.attachedPage.takeIf { _viewState.value.showContext } - submitPrompt(prompt, followUpPrefill, modelId, reasoningEffort, selectedTool, imagesJson, filesJson, attachedContext) + submitPrompt(prompt, followUpPrefill, modelId, reasoningEffort, selectedTool, imagesJson, filesJson, attachedContext, selectionsJson) } private fun submitPrompt( diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt index 8b136c72292d..d67fc75de3c5 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt @@ -457,14 +457,15 @@ class ContextualSuggestionsViewModelTest { } @Test - fun whenMoreThanOneTextSelectionAttachedThenNoSuggestionsShown() = runTest { - whenever(suggestedPromptsProvider.resolveTextSelectionSuggestions(any())).thenReturn( - listOf(ContextualSuggestedPrompt("summarize-selection", "Summarize this selection", "Summarize this selection.", "summary")), + fun whenMoreThanOneTextSelectionAttachedThenSelectionSuggestionsStillShown() = runTest { + val selectionSuggestions = listOf( + ContextualSuggestedPrompt("summarize-selection", "Summarize this selection", "Summarize this selection.", "summary"), ) + whenever(suggestedPromptsProvider.resolveTextSelectionSuggestions(any())).thenReturn(selectionSuggestions) viewModel.onTextSelectionCountChanged(1) viewModel.onTextSelectionCountChanged(2) - assertTrue(viewModel.viewState.value.suggestions.isEmpty()) + assertEquals(selectionSuggestions, viewModel.viewState.value.suggestions) } } From 004fdd9ff4bdc82d099578ce4909f00b9d4138d4 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 8 Sep 2026 23:06:38 +0100 Subject: [PATCH 55/73] Fix scroll bug --- .../impl/ui/nativeinput/views/AttachmentView.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index 6fbca6d3c3f1..9c9e1034ac84 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -18,6 +18,7 @@ package com.duckduckgo.duckchat.impl.ui.nativeinput.views import android.annotation.SuppressLint import android.content.Context +import android.graphics.Rect import android.graphics.Typeface import android.net.Uri import android.view.Gravity @@ -82,7 +83,6 @@ class AttachmentView( private var fileAttachmentsContainer: FileAttachmentsContainerView? = null private var pageContextContainer: PageContextAttachmentView? = null private var textSelectionsContainer: TextSelectionAttachmentsContainerView? = null - private var attachmentsScroll: HorizontalScrollView? = null private var limitErrorView: TextView? = null init { @@ -191,7 +191,6 @@ class AttachmentView( isHorizontalScrollBarEnabled = false } parent.addView(scroll) - attachmentsScroll = scroll val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL @@ -281,7 +280,15 @@ class AttachmentView( if (view.current() != state.textSelections) { val isNewAttachment = state.textSelections.size > view.current().size view.render(state.textSelections) - if (isNewAttachment) attachmentsScroll?.post { attachmentsScroll?.fullScroll(FOCUS_RIGHT) } + if (isNewAttachment) showLastTextSelection() + } + } + + private fun showLastTextSelection() { + val container = textSelectionsContainer ?: return + container.post { + val chip = container.getChildAt(container.childCount - 1) ?: return@post + chip.requestRectangleOnScreen(Rect(0, 0, chip.width, chip.height), true) } } From dd142b6967739092f3d915af4d2a4798baa565b3 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 01:32:07 +0100 Subject: [PATCH 56/73] Move 'Ask Duck.ai' to first position in DDG menu --- .../app/browser/DuckDuckGoWebView.kt | 11 +++ .../api/DuckAiTextSelectionDecorator.kt | 30 ++++++++ .../RealDuckAiTextSelectionDecorator.kt | 72 +++++++++++++++++++ .../duckchat-impl/src/main/res/values/ids.xml | 3 + 4 files changed, 116 insertions(+) create mode 100644 duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt index 1c443384bf76..c977f2d7e32f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt @@ -22,6 +22,7 @@ import android.os.Message import android.print.PrintDocumentAdapter import android.util.AttributeSet import android.util.SparseArray +import android.view.ActionMode import android.view.MotionEvent import android.view.WindowInsets import android.view.autofill.AutofillValue @@ -47,6 +48,7 @@ import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList import com.duckduckgo.app.browser.uilock.BrowserUiLockFeature import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ViewScope +import com.duckduckgo.duckchat.api.DuckAiTextSelectionDecorator import dagger.android.support.AndroidSupportInjection import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @@ -91,6 +93,9 @@ class DuckDuckGoWebView : @Inject lateinit var browserUiLockFeature: BrowserUiLockFeature + @Inject + lateinit var duckAiTextSelectionDecorator: DuckAiTextSelectionDecorator + constructor(context: Context) : this(context, null) constructor( context: Context, @@ -99,6 +104,12 @@ class DuckDuckGoWebView : isNestedScrollingEnabled = true } + override fun startActionMode(callback: ActionMode.Callback?): ActionMode? = + super.startActionMode(duckAiTextSelectionDecorator.decorate(callback)) + + override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? = + super.startActionMode(duckAiTextSelectionDecorator.decorate(callback), type) + override fun onAttachedToWindow() { AndroidSupportInjection.inject(this) super.onAttachedToWindow() diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt new file mode 100644 index 000000000000..baa930d73f62 --- /dev/null +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt @@ -0,0 +1,30 @@ +/* + * 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.view.ActionMode + +/** + * Customizes the text selection menu. + */ +interface DuckAiTextSelectionDecorator { + + /** + * Returns [callback] with "Ask Duck.ai" shown as a primary action rather than an overflow entry. + */ + fun decorate(callback: ActionMode.Callback?): ActionMode.Callback? +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt new file mode 100644 index 000000000000..139ad7646e9d --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt @@ -0,0 +1,72 @@ +/* + * 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.impl.ui.nativeinput.textselection + +import android.graphics.Rect +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.core.view.children +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckAiTextSelectionDecorator +import com.duckduckgo.duckchat.impl.R +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class RealDuckAiTextSelectionDecorator @Inject constructor() : DuckAiTextSelectionDecorator { + + override fun decorate(callback: ActionMode.Callback?): ActionMode.Callback? { + if (callback == null) return null + return object : ActionMode.Callback2() { + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean = callback.onCreateActionMode(mode, menu) + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + val isPrepared = callback.onPrepareActionMode(mode, menu) + menu?.promoteAskDuckAi() + return isPrepared + } + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean = callback.onActionItemClicked(mode, item) + + override fun onDestroyActionMode(mode: ActionMode?) = callback.onDestroyActionMode(mode) + + override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: Rect?) { + if (callback is ActionMode.Callback2) { + callback.onGetContentRect(mode, view, outRect) + } else { + super.onGetContentRect(mode, view, outRect) + } + } + } + } + + private fun Menu.promoteAskDuckAi() { + if (findItem(R.id.askDuckAi) != null) return + val processText = children.firstOrNull { it.intent?.component?.className == SELECTED_TEXT_ACTIVITY } ?: return + processText.isVisible = false + add(processText.groupId, R.id.askDuckAi, FIRST_ITEM, processText.title) + .setIntent(processText.intent) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + } + + private companion object { + private val SELECTED_TEXT_ACTIVITY = SelectedTextDuckAiActivity::class.java.name + private const val FIRST_ITEM = 0 + } +} diff --git a/duckchat/duckchat-impl/src/main/res/values/ids.xml b/duckchat/duckchat-impl/src/main/res/values/ids.xml index 40554d2bb302..25a15c1a6c8f 100644 --- a/duckchat/duckchat-impl/src/main/res/values/ids.xml +++ b/duckchat/duckchat-impl/src/main/res/values/ids.xml @@ -24,4 +24,7 @@ + + \ No newline at end of file From 667784a8a55225b880dce4e289c2976a5da84cee Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 01:48:23 +0100 Subject: [PATCH 57/73] Add initialized check --- .../java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt index c977f2d7e32f..d00ff0243f5d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt @@ -105,10 +105,13 @@ class DuckDuckGoWebView : } override fun startActionMode(callback: ActionMode.Callback?): ActionMode? = - super.startActionMode(duckAiTextSelectionDecorator.decorate(callback)) + super.startActionMode(decorate(callback)) override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? = - super.startActionMode(duckAiTextSelectionDecorator.decorate(callback), type) + super.startActionMode(decorate(callback), type) + + private fun decorate(callback: ActionMode.Callback?): ActionMode.Callback? = + if (::duckAiTextSelectionDecorator.isInitialized) duckAiTextSelectionDecorator.decorate(callback) else callback override fun onAttachedToWindow() { AndroidSupportInjection.inject(this) From 6d1f1de61fbe17ba2c107cb3b3935bff898e5bee Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 09:55:33 +0100 Subject: [PATCH 58/73] Fix submission bug --- .../app/browser/BrowserTabFragment.kt | 1 + .../browser/nativeinput/NativeInputManager.kt | 7 ++-- .../DuckChatContextualEntryViewModel.kt | 8 +---- .../impl/contextual/RealDuckChatContextual.kt | 2 +- .../duckchat/impl/ui/AttachmentViewModel.kt | 6 ++-- .../TextSelectionPayloadBuilder.kt | 12 +++---- .../textselection/TextSelectionStore.kt | 11 ++++--- .../ui/nativeinput/views/AttachmentView.kt | 4 +-- .../views/NativeInputModeWidget.kt | 10 +++--- .../DuckChatContextualEntryViewModelTest.kt | 32 +++++++++++++++---- .../impl/ui/AttachmentViewModelTest.kt | 16 +++++----- 11 files changed, 62 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 03b6981e2769..5dd55d474157 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -1420,6 +1420,7 @@ class BrowserTabFragment : initialInputMode = viewModel.consumeInitialInputMode(), forceImageGeneration = forceImageGeneration, textSelection = textSelection, + textSelectionUrl = webView?.url, callbacks = NativeInputCallbacks( onSearchTextChanged = { text -> onUserEnteredText(text) }, onClearAutocomplete = { diff --git a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt index 04bb0872edc7..7ada0be32aea 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt @@ -150,6 +150,7 @@ interface NativeInputManager { initialInputMode: InputMode? = null, forceImageGeneration: Boolean = false, textSelection: String? = null, + textSelectionUrl: String? = null, ) fun hideNativeInput(animate: Boolean = true, isNavigation: Boolean = false): Boolean @@ -591,6 +592,7 @@ class RealNativeInputManager @Inject constructor( initialInputMode: InputMode?, forceImageGeneration: Boolean, textSelection: String?, + textSelectionUrl: String?, ) { if (!isNativeInputFieldEnabled) return @@ -660,7 +662,7 @@ class RealNativeInputManager @Inject constructor( } } bindUrlCaching(widgetView) - attachWidget(widgetView, navBarView, isBottom, tabId, forceImageGeneration, textSelection) + attachWidget(widgetView, navBarView, isBottom, tabId, forceImageGeneration, textSelection, textSelectionUrl) // Bottom omnibar: slide the nav bar in with open. Top omnibar: snap the bar so the enter // morph can run from the omnibar while the buttons appear without animating — a concurrent // top slide fights that morph (and was only needed for bottom chrome). @@ -1243,6 +1245,7 @@ class RealNativeInputManager @Inject constructor( tabId: String, forceImageGeneration: Boolean, textSelection: String?, + textSelectionUrl: String?, ) { // Inflated from a ?attr/actionBarSize height, so layoutParams carries the resolved nav bar height. val navBarHeightPx = navBarView?.layoutParams?.height?.takeIf { it > 0 } ?: 0 @@ -1277,7 +1280,7 @@ class RealNativeInputManager @Inject constructor( isBottom = isBottom, forceImageGeneration = forceImageGeneration, ) - bindTextSelections(tabId, textSelection) + bindTextSelections(tabId, textSelection, textSelectionUrl.orEmpty()) } applyWindowChrome(widgetView, isBottom) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt index 503badefcc48..2cc7798e07d3 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt @@ -154,19 +154,13 @@ class DuckChatContextualEntryViewModel @Inject constructor( tabId = tabId, prompt = prompt, serializedPageContext = _viewState.value.attachedContext?.serialized, - selectionsJson = selectionPayloadBuilder.toJson(selections = selections, url = pageUrl()), + selectionsJson = selectionPayloadBuilder.toJson(selections), ), ) duckChatPixels.reportContextualFloatingInputPromotedToSheet() commandChannel.trySend(Command.HandOffToSheet) } - private fun pageUrl(): String = - latestValidPageContext - ?.let { runCatching { JSONObject(it) }.getOrNull() } - ?.optString("url") - .orEmpty() - private fun hasTextSelections(): Boolean = textSelectionStore.selections(tabId).value.isNotEmpty() private fun attach(serializedPageContext: String) { diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt index 9f277a9b1f4d..223c3f6d79c8 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt @@ -66,7 +66,7 @@ class RealDuckChatContextual @Inject constructor( showChatSurface() return } - textSelection?.let { textSelectionStore.add(sourceTabId, it) } + textSelection?.let { textSelectionStore.add(sourceTabId, it, sourceUrl.orEmpty()) } if (hasChatInProgress(sourceTabId)) { // The sheet would reopen the existing chat for this tab, so skip the entry menu and open it directly. showChatSurface() diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index ec007afb2d51..b052a95d772f 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -358,9 +358,9 @@ class AttachmentViewModel @Inject constructor( val textSelections: List, ) - fun bindTextSelections(tabId: String, textSelection: String?) { + fun bindTextSelections(tabId: String, textSelection: String?, url: String) { textSelectionsTabId = tabId - textSelection?.let { textSelectionStore.add(tabId, it) } + textSelection?.let { textSelectionStore.add(tabId, it, url) } textSelectionsJob?.cancel() textSelectionsJob = viewModelScope.launch { textSelectionStore.selections(tabId).collect { selections -> @@ -375,7 +375,7 @@ class AttachmentViewModel @Inject constructor( fun getTextSelectionsJson(): JSONArray? { val tabId = textSelectionsTabId ?: return null - return textSelectionPayloadBuilder.toJson(selections = textSelectionStore.consume(tabId), url = "") + return textSelectionPayloadBuilder.toJson(textSelectionStore.consume(tabId)) } fun setPageContext(attachment: PageContextAttachment) { diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt index e5b171366cfc..6e9c0cd63c70 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt @@ -26,7 +26,7 @@ import org.json.JSONObject import javax.inject.Inject interface TextSelectionPayloadBuilder { - fun toJson(selections: List, url: String): JSONArray? + fun toJson(selections: List): JSONArray? companion object { const val MAX_CONTENT_LENGTH = 9500 @@ -39,28 +39,24 @@ class RealTextSelectionPayloadBuilder @Inject constructor( private val context: Context, ) : TextSelectionPayloadBuilder { - override fun toJson( - selections: List, - url: String, - ): JSONArray? { + override fun toJson(selections: List): JSONArray? { val title = context.getString(R.string.duckAiTextSelectionAttachmentTitle) if (selections.isEmpty()) return null return JSONArray().apply { - selections.forEach { selection -> put(toJson(selection, title, url)) } + selections.forEach { selection -> put(toJson(selection, title)) } } } private fun toJson( selection: TextSelection, title: String, - url: String, ): JSONObject { val truncated = selection.text.length > TextSelectionPayloadBuilder.MAX_CONTENT_LENGTH return JSONObject().apply { put("id", selection.id) put("title", title) put("favicon", JSONArray()) - put("url", url) + put("url", selection.url) put("content", if (truncated) selection.text.take(TextSelectionPayloadBuilder.MAX_CONTENT_LENGTH) else selection.text) put("truncated", truncated) put("fullContentLength", selection.text.length) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt index f1e072b2d7f8..d57b9b41a283 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt @@ -31,11 +31,12 @@ import javax.inject.Inject data class TextSelection( val id: String, val text: String, + val url: String, ) interface TextSelectionStore { fun selections(tabId: String): StateFlow> - fun add(tabId: String, text: String): Boolean + fun add(tabId: String, text: String, url: String): Boolean fun consume(tabId: String): List fun remove(tabId: String, id: String) @@ -52,8 +53,8 @@ class RealTextSelectionStore @Inject constructor() : TextSelectionStore { override fun selections(tabId: String): StateFlow> = getFlow(tabId) - override fun add(tabId: String, text: String): Boolean { - val selection = getTextSelection(text) ?: return false + override fun add(tabId: String, text: String, url: String): Boolean { + val selection = getTextSelection(text, url) ?: return false val selections = getFlow(tabId).updateAndGet { existing -> val isDuplicate = existing.any { it.text == selection.text } if (isDuplicate || existing.size >= TextSelectionStore.MAX_SELECTIONS) existing else existing + selection @@ -70,9 +71,9 @@ class RealTextSelectionStore @Inject constructor() : TextSelectionStore { private fun getFlow(tabId: String): MutableStateFlow> = selections.computeIfAbsent(tabId) { MutableStateFlow(emptyList()) } - private fun getTextSelection(text: String): TextSelection? { + private fun getTextSelection(text: String, url: String): TextSelection? { val trimmed = text.trim() if (trimmed.isEmpty()) return null - return TextSelection(UUID.randomUUID().toString(), trimmed) + return TextSelection(UUID.randomUUID().toString(), trimmed, url) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index 9c9e1034ac84..17346ed09ba8 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -134,8 +134,8 @@ class AttachmentView( fun getTextSelectionsJson(): JSONArray? = viewModel?.getTextSelectionsJson() - fun bindTextSelections(tabId: String, textSelection: String?) { - viewModel?.bindTextSelections(tabId, textSelection) + fun bindTextSelections(tabId: String, textSelection: String?, url: String) { + viewModel?.bindTextSelections(tabId, textSelection, url) onTextSelectionRemoved = { id -> viewModel?.removeTextSelection(id) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index c46d57b93a52..aa7bc422e593 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -193,7 +193,7 @@ interface NativeInputWidget { fun setWidgetPosition(isBottom: Boolean) fun setWidgetRootView(view: View) - fun bindTextSelections(tabId: String, textSelection: String?) + fun bindTextSelections(tabId: String, textSelection: String?, url: String = "") fun getTextSelectionsJson(): JSONArray? /** @@ -397,6 +397,7 @@ class NativeInputModeWidget @JvmOverloads constructor( private var pendingPageContext: PageContextAttachment? = null private var pendingTextSelectionsTabId: String? = null private var pendingTextSelection: String? = null + private var pendingTextSelectionUrl: String = "" // adoptEditAttachments() can be called (from EditPromptActivity.onCreate) before the widget is // attached and the AttachmentView plugin exists, so the values are held here and applied once @@ -795,7 +796,7 @@ class NativeInputModeWidget @JvmOverloads constructor( pluginView.onPageContextRemoved = pendingOnPageContextRemoved pluginView.bind(scope, viewModelFactory, nativeInputStateProvider, faviconManager) pendingPageContext?.let { pluginView.setPageContext(it) } - pendingTextSelectionsTabId?.let { bindTextSelections(it, pendingTextSelection) } + pendingTextSelectionsTabId?.let { bindTextSelections(it, pendingTextSelection, pendingTextSelectionUrl) } if (hasPendingAdoptedAttachments(pendingAdoptedImages, pendingAdoptedFiles)) { pluginView.adoptAttachments(pendingAdoptedImages, pendingAdoptedFiles) } @@ -1653,11 +1654,12 @@ class NativeInputModeWidget @JvmOverloads constructor( override fun getPageContext(): PageContextAttachment? = attachmentView?.getPageContext() - override fun bindTextSelections(tabId: String, textSelection: String?) { + override fun bindTextSelections(tabId: String, textSelection: String?, url: String) { pendingTextSelectionsTabId = tabId pendingTextSelection = textSelection + pendingTextSelectionUrl = url attachmentView?.let { view -> - view.bindTextSelections(tabId, textSelection) + view.bindTextSelections(tabId, textSelection, url) pendingTextSelection = null } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt index a32555c83ca9..02fc8e0f733a 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt @@ -298,7 +298,7 @@ class DuckChatContextualEntryViewModelTest { @Test fun whenTextSelectionAttachedThenPageContextNotAttached() = runTest { viewModel.start("tab-1") - textSelectionStore.add("tab-1", "selected words") + textSelectionStore.add("tab-1", "selected words", "https://example.com") viewModel.onPageContextReceived(validContext) @@ -309,8 +309,8 @@ class DuckChatContextualEntryViewModelTest { fun whenPromptSubmittedWithTextSelectionsThenSelectionsSentOnOwnKeyAndCleared() = runTest { viewModel.start("tab-1") viewModel.onPageContextReceived(validContext) - textSelectionStore.add("tab-1", "first selection") - textSelectionStore.add("tab-1", "second selection") + textSelectionStore.add("tab-1", "first selection", "https://example.com") + textSelectionStore.add("tab-1", "second selection", "https://example.com") viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) @@ -346,11 +346,29 @@ class DuckChatContextualEntryViewModelTest { assertEquals(validContext, captor.firstValue.serializedPageContext) } + @Test + fun whenSelectionsComeFromDifferentPagesThenEachCarriesItsOwnUrl() = runTest { + viewModel.start("tab-1") + textSelectionStore.add("tab-1", "from imdb", "https://imdb.com") + textSelectionStore.add("tab-1", "from wikipedia", "https://wikipedia.org") + + viewModel.commands.test { + viewModel.onPromptSubmitted(samplePrompt) + assertEquals(DuckChatContextualEntryViewModel.Command.HandOffToSheet, awaitItem()) + } + + val captor = argumentCaptor() + verify(store).store(captor.capture()) + val selections = captor.firstValue.selectionsJson!! + assertEquals("https://imdb.com", selections.getJSONObject(0).getString("url")) + assertEquals("https://wikipedia.org", selections.getJSONObject(1).getString("url")) + } + @Test fun whenSelectionExceedsMaxContentLengthThenTruncatedButSizeReported() = runTest { val long = "word ".repeat(3000) viewModel.start("tab-1") - textSelectionStore.add("tab-1", long) + textSelectionStore.add("tab-1", long, "https://example.com") viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) @@ -370,15 +388,15 @@ class DuckChatContextualEntryViewModelTest { fun whenSelectionsExceedMaxThenExtrasDropped() { repeat( TextSelectionStore.MAX_SELECTIONS + 2, - ) { index -> textSelectionStore.add("tab-1", "selection $index") } + ) { index -> textSelectionStore.add("tab-1", "selection $index", "https://example.com") } assertEquals(TextSelectionStore.MAX_SELECTIONS, textSelectionStore.consume("tab-1").size) } @Test fun whenSelectionRemovedThenDroppedFromStore() { - textSelectionStore.add("tab-1", "keep me") - textSelectionStore.add("tab-1", "remove me") + textSelectionStore.add("tab-1", "keep me", "https://example.com") + textSelectionStore.add("tab-1", "remove me", "https://example.com") val target = textSelectionStore.selections("tab-1").value.last() textSelectionStore.remove("tab-1", target.id) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt index f24dd8c86fc1..e0908aa13302 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt @@ -286,7 +286,7 @@ class AttachmentViewModelTest { @Test fun whenOnlyTextSelectionsAttachedThenHasNoStandaloneAttachments() = runTest { - viewModel.bindTextSelections("tab-1", "selected words") + viewModel.bindTextSelections("tab-1", "selected words", "https://example.com") assertTrue(viewModel.attachmentState.value.hasAttachments) assertFalse(viewModel.attachmentState.value.hasStandaloneAttachments) @@ -308,31 +308,31 @@ class AttachmentViewModelTest { @Test fun whenBoundWithTextSelectionThenItIsAttachedToThatTab() = runTest { - viewModel.bindTextSelections("tab-1", "selected words") + viewModel.bindTextSelections("tab-1", "selected words", "https://example.com") assertEquals(listOf("selected words"), viewModel.attachmentState.value.textSelections.map { it.text }) } @Test fun whenBoundWithoutTextSelectionThenNothingIsAttached() = runTest { - viewModel.bindTextSelections("tab-1", textSelection = null) + viewModel.bindTextSelections("tab-1", textSelection = null, url = "") assertTrue(viewModel.attachmentState.value.textSelections.isEmpty()) } @Test fun whenBoundToADifferentTabThenOtherTabsSelectionsAreNotShown() = runTest { - textSelectionStore.add("tab-other", "not mine") + textSelectionStore.add("tab-other", "not mine", "https://example.com") - viewModel.bindTextSelections("tab-1", textSelection = null) + viewModel.bindTextSelections("tab-1", textSelection = null, url = "") assertTrue(viewModel.attachmentState.value.textSelections.isEmpty()) } @Test fun whenTextSelectionRemovedThenItIsDroppedFromState() = runTest { - viewModel.bindTextSelections("tab-1", "keep me") - viewModel.bindTextSelections("tab-1", "remove me") + viewModel.bindTextSelections("tab-1", "keep me", "https://example.com") + viewModel.bindTextSelections("tab-1", "remove me", "https://example.com") val idToRemove = viewModel.attachmentState.value.textSelections.last().id viewModel.removeTextSelection(idToRemove) @@ -342,7 +342,7 @@ class AttachmentViewModelTest { @Test fun whenTextSelectionsConsumedThenTheyAreClearedFromState() = runTest { - viewModel.bindTextSelections("tab-1", "selected words") + viewModel.bindTextSelections("tab-1", "selected words", "https://example.com") viewModel.getTextSelectionsJson() From df6115a70751bdc5ef1c9df6c13d618ad8584962 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 10:06:35 +0100 Subject: [PATCH 59/73] Fix wrong URL issue --- .../duckduckgo/app/browser/BrowserTabFragment.kt | 1 - .../app/browser/nativeinput/NativeInputManager.kt | 7 ++----- .../duckchat/impl/ui/AttachmentViewModel.kt | 4 ++-- .../impl/ui/nativeinput/views/AttachmentView.kt | 4 ++-- .../ui/nativeinput/views/NativeInputModeWidget.kt | 10 ++++------ .../duckchat/impl/ui/AttachmentViewModelTest.kt | 14 +++++++------- 6 files changed, 17 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 5dd55d474157..03b6981e2769 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -1420,7 +1420,6 @@ class BrowserTabFragment : initialInputMode = viewModel.consumeInitialInputMode(), forceImageGeneration = forceImageGeneration, textSelection = textSelection, - textSelectionUrl = webView?.url, callbacks = NativeInputCallbacks( onSearchTextChanged = { text -> onUserEnteredText(text) }, onClearAutocomplete = { diff --git a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt index 7ada0be32aea..04bb0872edc7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt @@ -150,7 +150,6 @@ interface NativeInputManager { initialInputMode: InputMode? = null, forceImageGeneration: Boolean = false, textSelection: String? = null, - textSelectionUrl: String? = null, ) fun hideNativeInput(animate: Boolean = true, isNavigation: Boolean = false): Boolean @@ -592,7 +591,6 @@ class RealNativeInputManager @Inject constructor( initialInputMode: InputMode?, forceImageGeneration: Boolean, textSelection: String?, - textSelectionUrl: String?, ) { if (!isNativeInputFieldEnabled) return @@ -662,7 +660,7 @@ class RealNativeInputManager @Inject constructor( } } bindUrlCaching(widgetView) - attachWidget(widgetView, navBarView, isBottom, tabId, forceImageGeneration, textSelection, textSelectionUrl) + attachWidget(widgetView, navBarView, isBottom, tabId, forceImageGeneration, textSelection) // Bottom omnibar: slide the nav bar in with open. Top omnibar: snap the bar so the enter // morph can run from the omnibar while the buttons appear without animating — a concurrent // top slide fights that morph (and was only needed for bottom chrome). @@ -1245,7 +1243,6 @@ class RealNativeInputManager @Inject constructor( tabId: String, forceImageGeneration: Boolean, textSelection: String?, - textSelectionUrl: String?, ) { // Inflated from a ?attr/actionBarSize height, so layoutParams carries the resolved nav bar height. val navBarHeightPx = navBarView?.layoutParams?.height?.takeIf { it > 0 } ?: 0 @@ -1280,7 +1277,7 @@ class RealNativeInputManager @Inject constructor( isBottom = isBottom, forceImageGeneration = forceImageGeneration, ) - bindTextSelections(tabId, textSelection, textSelectionUrl.orEmpty()) + bindTextSelections(tabId, textSelection) } applyWindowChrome(widgetView, isBottom) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index b052a95d772f..a7e455205464 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -358,9 +358,9 @@ class AttachmentViewModel @Inject constructor( val textSelections: List, ) - fun bindTextSelections(tabId: String, textSelection: String?, url: String) { + fun bindTextSelections(tabId: String, textSelection: String?) { textSelectionsTabId = tabId - textSelection?.let { textSelectionStore.add(tabId, it, url) } + textSelection?.let { textSelectionStore.add(tabId, it, url = "") } textSelectionsJob?.cancel() textSelectionsJob = viewModelScope.launch { textSelectionStore.selections(tabId).collect { selections -> diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index 17346ed09ba8..9c9e1034ac84 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -134,8 +134,8 @@ class AttachmentView( fun getTextSelectionsJson(): JSONArray? = viewModel?.getTextSelectionsJson() - fun bindTextSelections(tabId: String, textSelection: String?, url: String) { - viewModel?.bindTextSelections(tabId, textSelection, url) + fun bindTextSelections(tabId: String, textSelection: String?) { + viewModel?.bindTextSelections(tabId, textSelection) onTextSelectionRemoved = { id -> viewModel?.removeTextSelection(id) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index aa7bc422e593..c46d57b93a52 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -193,7 +193,7 @@ interface NativeInputWidget { fun setWidgetPosition(isBottom: Boolean) fun setWidgetRootView(view: View) - fun bindTextSelections(tabId: String, textSelection: String?, url: String = "") + fun bindTextSelections(tabId: String, textSelection: String?) fun getTextSelectionsJson(): JSONArray? /** @@ -397,7 +397,6 @@ class NativeInputModeWidget @JvmOverloads constructor( private var pendingPageContext: PageContextAttachment? = null private var pendingTextSelectionsTabId: String? = null private var pendingTextSelection: String? = null - private var pendingTextSelectionUrl: String = "" // adoptEditAttachments() can be called (from EditPromptActivity.onCreate) before the widget is // attached and the AttachmentView plugin exists, so the values are held here and applied once @@ -796,7 +795,7 @@ class NativeInputModeWidget @JvmOverloads constructor( pluginView.onPageContextRemoved = pendingOnPageContextRemoved pluginView.bind(scope, viewModelFactory, nativeInputStateProvider, faviconManager) pendingPageContext?.let { pluginView.setPageContext(it) } - pendingTextSelectionsTabId?.let { bindTextSelections(it, pendingTextSelection, pendingTextSelectionUrl) } + pendingTextSelectionsTabId?.let { bindTextSelections(it, pendingTextSelection) } if (hasPendingAdoptedAttachments(pendingAdoptedImages, pendingAdoptedFiles)) { pluginView.adoptAttachments(pendingAdoptedImages, pendingAdoptedFiles) } @@ -1654,12 +1653,11 @@ class NativeInputModeWidget @JvmOverloads constructor( override fun getPageContext(): PageContextAttachment? = attachmentView?.getPageContext() - override fun bindTextSelections(tabId: String, textSelection: String?, url: String) { + override fun bindTextSelections(tabId: String, textSelection: String?) { pendingTextSelectionsTabId = tabId pendingTextSelection = textSelection - pendingTextSelectionUrl = url attachmentView?.let { view -> - view.bindTextSelections(tabId, textSelection, url) + view.bindTextSelections(tabId, textSelection) pendingTextSelection = null } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt index e0908aa13302..d490a2c280b7 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt @@ -286,7 +286,7 @@ class AttachmentViewModelTest { @Test fun whenOnlyTextSelectionsAttachedThenHasNoStandaloneAttachments() = runTest { - viewModel.bindTextSelections("tab-1", "selected words", "https://example.com") + viewModel.bindTextSelections("tab-1", "selected words") assertTrue(viewModel.attachmentState.value.hasAttachments) assertFalse(viewModel.attachmentState.value.hasStandaloneAttachments) @@ -308,14 +308,14 @@ class AttachmentViewModelTest { @Test fun whenBoundWithTextSelectionThenItIsAttachedToThatTab() = runTest { - viewModel.bindTextSelections("tab-1", "selected words", "https://example.com") + viewModel.bindTextSelections("tab-1", "selected words") assertEquals(listOf("selected words"), viewModel.attachmentState.value.textSelections.map { it.text }) } @Test fun whenBoundWithoutTextSelectionThenNothingIsAttached() = runTest { - viewModel.bindTextSelections("tab-1", textSelection = null, url = "") + viewModel.bindTextSelections("tab-1", textSelection = null) assertTrue(viewModel.attachmentState.value.textSelections.isEmpty()) } @@ -324,15 +324,15 @@ class AttachmentViewModelTest { fun whenBoundToADifferentTabThenOtherTabsSelectionsAreNotShown() = runTest { textSelectionStore.add("tab-other", "not mine", "https://example.com") - viewModel.bindTextSelections("tab-1", textSelection = null, url = "") + viewModel.bindTextSelections("tab-1", textSelection = null) assertTrue(viewModel.attachmentState.value.textSelections.isEmpty()) } @Test fun whenTextSelectionRemovedThenItIsDroppedFromState() = runTest { - viewModel.bindTextSelections("tab-1", "keep me", "https://example.com") - viewModel.bindTextSelections("tab-1", "remove me", "https://example.com") + viewModel.bindTextSelections("tab-1", "keep me") + viewModel.bindTextSelections("tab-1", "remove me") val idToRemove = viewModel.attachmentState.value.textSelections.last().id viewModel.removeTextSelection(idToRemove) @@ -342,7 +342,7 @@ class AttachmentViewModelTest { @Test fun whenTextSelectionsConsumedThenTheyAreClearedFromState() = runTest { - viewModel.bindTextSelections("tab-1", "selected words", "https://example.com") + viewModel.bindTextSelections("tab-1", "selected words") viewModel.getTextSelectionsJson() From 899aee69d016b6ad85762bf4d07c1d4da46da3e4 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 10:20:48 +0100 Subject: [PATCH 60/73] Fix attachment bug --- .../duckduckgo/app/browser/nativeinput/NativeInputManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt index 04bb0872edc7..d0aa5c0acc35 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt @@ -1277,7 +1277,7 @@ class RealNativeInputManager @Inject constructor( isBottom = isBottom, forceImageGeneration = forceImageGeneration, ) - bindTextSelections(tabId, textSelection) + textSelection?.let { bindTextSelections(tabId, it) } } applyWindowChrome(widgetView, isBottom) From 6496eea3889210c749895e2f321decf71b8ed8e6 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 11:53:13 +0100 Subject: [PATCH 61/73] Fix submission bug --- .../DuckChatContextualEntryViewModel.kt | 4 +-- .../duckchat/impl/helper/DuckChatJSHelper.kt | 6 +++- .../TextSelectionPayloadBuilder.kt | 7 +++- .../DuckChatContextualEntryViewModelTest.kt | 35 ++++++++++++++++++- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt index 2cc7798e07d3..e9a82db37d16 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt @@ -148,13 +148,13 @@ class DuckChatContextualEntryViewModel @Inject constructor( } private fun submit(prompt: NativeInputPrompt) { - val selections = textSelectionStore.consume(tabId) + val selectionsJson = prompt.selectionsJson ?: selectionPayloadBuilder.toJson(textSelectionStore.consume(tabId)) contextualEntryPromptStore.store( ContextualEntryPrompt( tabId = tabId, prompt = prompt, serializedPageContext = _viewState.value.attachedContext?.serialized, - selectionsJson = selectionPayloadBuilder.toJson(selections), + selectionsJson = selectionsJson, ), ) duckChatPixels.reportContextualFloatingInputPromotedToSheet() diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt index 68bab9b686ed..632003258939 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt @@ -458,6 +458,10 @@ class RealDuckChatJSHelper @Inject constructor( mode == Mode.CONTEXTUAL && duckChatFeature.contextualSuggestedPrompts().isEnabled() } + val supportsPageContext = withContext(dispatcherProvider.io()) { + duckChat.isDuckChatContextualModeEnabled() && + (mode == Mode.CONTEXTUAL || duckChatFeature.duckAiTextSelectionAction().isEnabled()) + } val jsonPayload = JSONObject().apply { put(PLATFORM, ANDROID) @@ -473,7 +477,7 @@ class RealDuckChatJSHelper @Inject constructor( put(SUPPORTS_CHAT_FULLSCREEN_MODE, duckChat.isDuckChatFullScreenModeEnabled() && mode == Mode.FULL) put(SUPPORTS_CHAT_CONTEXTUAL_MODE, duckChat.isDuckChatContextualModeEnabled() && mode == Mode.CONTEXTUAL) put(SUPPORTS_CHAT_SYNC, duckChat.isChatSyncFeatureEnabled() && browserMode.isSyncable) - put(SUPPORTS_PAGE_CONTEXT, duckChat.isDuckChatContextualModeEnabled() && mode == Mode.CONTEXTUAL) + put(SUPPORTS_PAGE_CONTEXT, supportsPageContext) put(SUPPORTS_NATIVE_STORAGE, duckChat.isNativeStorageEnabled()) put( SUPPORTS_MULTIPLE_PAGE_CONTEXT, diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt index 6e9c0cd63c70..6c1e00243f4a 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt @@ -18,6 +18,7 @@ package com.duckduckgo.duckchat.impl.ui.nativeinput.textselection import android.content.Context import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckAiHostProvider import com.duckduckgo.duckchat.impl.R import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn @@ -37,8 +38,12 @@ interface TextSelectionPayloadBuilder { @ContributesBinding(AppScope::class) class RealTextSelectionPayloadBuilder @Inject constructor( private val context: Context, + duckAiHostProvider: DuckAiHostProvider, ) : TextSelectionPayloadBuilder { + // Default to duck.ai if no url is provided + private val duckAiUrl = "https://${duckAiHostProvider.getHost()}" + override fun toJson(selections: List): JSONArray? { val title = context.getString(R.string.duckAiTextSelectionAttachmentTitle) if (selections.isEmpty()) return null @@ -56,7 +61,7 @@ class RealTextSelectionPayloadBuilder @Inject constructor( put("id", selection.id) put("title", title) put("favicon", JSONArray()) - put("url", selection.url) + put("url", selection.url.ifBlank { duckAiUrl }) put("content", if (truncated) selection.text.take(TextSelectionPayloadBuilder.MAX_CONTENT_LENGTH) else selection.text) put("truncated", truncated) put("fullContentLength", selection.text.length) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt index 02fc8e0f733a..c4ccd2fa1c52 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt @@ -18,6 +18,7 @@ package com.duckduckgo.duckchat.impl.contextual import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test +import com.duckduckgo.duckchat.api.DuckAiHostProvider import com.duckduckgo.duckchat.impl.models.DuckAiModelManager import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface @@ -27,6 +28,8 @@ import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelecti import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore import kotlinx.coroutines.test.runTest +import org.json.JSONArray +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -54,7 +57,7 @@ class DuckChatContextualEntryViewModelTest { duckChatPixels, modelManager, textSelectionStore, - RealTextSelectionPayloadBuilder(RuntimeEnvironment.getApplication()), + RealTextSelectionPayloadBuilder(RuntimeEnvironment.getApplication(), object : DuckAiHostProvider {}), ) private val validContext = """{"title":"Example","url":"https://example.com","content":"some page content"}""" @@ -346,6 +349,36 @@ class DuckChatContextualEntryViewModelTest { assertEquals(validContext, captor.firstValue.serializedPageContext) } + @Test + fun whenComposerAlreadyBuiltSelectionsThenTheyAreNotDroppedByTheStoreRead() = runTest { + val composerSelections = JSONArray().apply { put(JSONObject().apply { put("content", "typed prompt selection") }) } + viewModel.start("tab-1") + + viewModel.commands.test { + viewModel.onPromptSubmitted(samplePrompt.copy(selectionsJson = composerSelections)) + assertEquals(DuckChatContextualEntryViewModel.Command.HandOffToSheet, awaitItem()) + } + + val captor = argumentCaptor() + verify(store).store(captor.capture()) + assertEquals(composerSelections, captor.firstValue.selectionsJson) + } + + @Test + fun whenSelectionHasNoSourcePageThenPayloadReportsDuckAi() = runTest { + viewModel.start("tab-1") + textSelectionStore.add("tab-1", "from another app", "") + + viewModel.commands.test { + viewModel.onPromptSubmitted(samplePrompt) + assertEquals(DuckChatContextualEntryViewModel.Command.HandOffToSheet, awaitItem()) + } + + val captor = argumentCaptor() + verify(store).store(captor.capture()) + assertEquals("https://duck.ai", captor.firstValue.selectionsJson!!.getJSONObject(0).getString("url")) + } + @Test fun whenSelectionsComeFromDifferentPagesThenEachCarriesItsOwnUrl() = runTest { viewModel.start("tab-1") From e15610f808ae939b620d2fd240e4f7d4ba3f0b86 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 12:05:04 +0100 Subject: [PATCH 62/73] Finish action mode when tapping action --- .../textselection/RealDuckAiTextSelectionDecorator.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt index 139ad7646e9d..19ce53237c6e 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt @@ -42,7 +42,11 @@ class RealDuckAiTextSelectionDecorator @Inject constructor() : DuckAiTextSelecti return isPrepared } - override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean = callback.onActionItemClicked(mode, item) + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + val isHandled = callback.onActionItemClicked(mode, item) + if (item?.itemId == R.id.askDuckAi) mode?.finish() + return isHandled + } override fun onDestroyActionMode(mode: ActionMode?) = callback.onDestroyActionMode(mode) From 05e3a56a2bdb8b15c77e416d3a57ba6b67800f10 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 14:08:28 +0100 Subject: [PATCH 63/73] Hide action on Duck.ai tab --- .../duckduckgo/app/browser/DuckDuckGoWebView.kt | 2 +- .../duckchat/api/DuckAiTextSelectionDecorator.kt | 4 +++- .../RealDuckAiTextSelectionDecorator.kt | 14 ++++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt index d00ff0243f5d..20ce9f5deb72 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt @@ -111,7 +111,7 @@ class DuckDuckGoWebView : super.startActionMode(decorate(callback), type) private fun decorate(callback: ActionMode.Callback?): ActionMode.Callback? = - if (::duckAiTextSelectionDecorator.isInitialized) duckAiTextSelectionDecorator.decorate(callback) else callback + if (::duckAiTextSelectionDecorator.isInitialized) duckAiTextSelectionDecorator.decorate(callback, pageUrl = url) else callback override fun onAttachedToWindow() { AndroidSupportInjection.inject(this) diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt index baa930d73f62..514d47f0ed1a 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt @@ -25,6 +25,8 @@ interface DuckAiTextSelectionDecorator { /** * Returns [callback] with "Ask Duck.ai" shown as a primary action rather than an overflow entry. + * + * @param pageUrl the page the text was selected on. The action is hidden for Duck.ai tabs. */ - fun decorate(callback: ActionMode.Callback?): ActionMode.Callback? + fun decorate(callback: ActionMode.Callback?, pageUrl: String?): ActionMode.Callback? } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt index 19ce53237c6e..f37c93989c24 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt @@ -21,24 +21,29 @@ import android.view.ActionMode import android.view.Menu import android.view.MenuItem import android.view.View +import androidx.core.net.toUri import androidx.core.view.children import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.api.DuckAiTextSelectionDecorator +import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.R import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @ContributesBinding(AppScope::class) -class RealDuckAiTextSelectionDecorator @Inject constructor() : DuckAiTextSelectionDecorator { +class RealDuckAiTextSelectionDecorator @Inject constructor( + private val duckChatInternal: DuckChatInternal, +) : DuckAiTextSelectionDecorator { - override fun decorate(callback: ActionMode.Callback?): ActionMode.Callback? { + override fun decorate(callback: ActionMode.Callback?, pageUrl: String?): ActionMode.Callback? { if (callback == null) return null + val hideAskDuckAi = pageUrl != null && duckChatInternal.isDuckChatUrl(pageUrl.toUri()) return object : ActionMode.Callback2() { override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean = callback.onCreateActionMode(mode, menu) override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { val isPrepared = callback.onPrepareActionMode(mode, menu) - menu?.promoteAskDuckAi() + menu?.decorateWithDuckAi(isHidden = hideAskDuckAi) return isPrepared } @@ -60,10 +65,11 @@ class RealDuckAiTextSelectionDecorator @Inject constructor() : DuckAiTextSelecti } } - private fun Menu.promoteAskDuckAi() { + private fun Menu.decorateWithDuckAi(isHidden: Boolean) { if (findItem(R.id.askDuckAi) != null) return val processText = children.firstOrNull { it.intent?.component?.className == SELECTED_TEXT_ACTIVITY } ?: return processText.isVisible = false + if (isHidden) return add(processText.groupId, R.id.askDuckAi, FIRST_ITEM, processText.title) .setIntent(processText.intent) .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) From d647d15d767bf087721cdefdeb05bfd380a1fec1 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 14:27:31 +0100 Subject: [PATCH 64/73] Update comment --- .../duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt index 514d47f0ed1a..e7d11778168a 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt @@ -25,8 +25,9 @@ interface DuckAiTextSelectionDecorator { /** * Returns [callback] with "Ask Duck.ai" shown as a primary action rather than an overflow entry. + * The action is hidden for Duck.ai tabs. * - * @param pageUrl the page the text was selected on. The action is hidden for Duck.ai tabs. + * @param pageUrl the page the text was selected on. */ fun decorate(callback: ActionMode.Callback?, pageUrl: String?): ActionMode.Callback? } From 576219ea90171aedff8bd0c71908ec9e834bf197 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 16:03:15 +0100 Subject: [PATCH 65/73] Remove unnecessary flag change --- .../com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt index 632003258939..68bab9b686ed 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt @@ -458,10 +458,6 @@ class RealDuckChatJSHelper @Inject constructor( mode == Mode.CONTEXTUAL && duckChatFeature.contextualSuggestedPrompts().isEnabled() } - val supportsPageContext = withContext(dispatcherProvider.io()) { - duckChat.isDuckChatContextualModeEnabled() && - (mode == Mode.CONTEXTUAL || duckChatFeature.duckAiTextSelectionAction().isEnabled()) - } val jsonPayload = JSONObject().apply { put(PLATFORM, ANDROID) @@ -477,7 +473,7 @@ class RealDuckChatJSHelper @Inject constructor( put(SUPPORTS_CHAT_FULLSCREEN_MODE, duckChat.isDuckChatFullScreenModeEnabled() && mode == Mode.FULL) put(SUPPORTS_CHAT_CONTEXTUAL_MODE, duckChat.isDuckChatContextualModeEnabled() && mode == Mode.CONTEXTUAL) put(SUPPORTS_CHAT_SYNC, duckChat.isChatSyncFeatureEnabled() && browserMode.isSyncable) - put(SUPPORTS_PAGE_CONTEXT, supportsPageContext) + put(SUPPORTS_PAGE_CONTEXT, duckChat.isDuckChatContextualModeEnabled() && mode == Mode.CONTEXTUAL) put(SUPPORTS_NATIVE_STORAGE, duckChat.isNativeStorageEnabled()) put( SUPPORTS_MULTIPLE_PAGE_CONTEXT, From 9d23ae0c8d11bef1f347e3bdcdfec09f96910935 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 16:43:58 +0100 Subject: [PATCH 66/73] Add limit error --- .../duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt | 9 +++++++++ .../duckchat/impl/ui/nativeinput/views/AttachmentView.kt | 3 ++- .../duckchat-impl/src/main/res/values/donottranslate.xml | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index a7e455205464..00f26cc57662 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -94,6 +94,7 @@ class AttachmentViewModel @Inject constructor( val files: List = emptyList(), val pageContext: PageContextAttachment? = null, val textSelections: List = emptyList(), + val textSelectionLimitError: String? = null, val imageLimitError: String? = null, val fileLimitError: String? = null, val fileSizeError: String? = null, @@ -152,6 +153,7 @@ class AttachmentViewModel @Inject constructor( files = files, pageContext = pageContext, textSelections = selections, + textSelectionLimitError = computeTextSelectionLimitError(selections.size), imageLimitError = computeImageLimitError(currentImageCount, totalImages, imageLimits), fileLimitError = computeFileLimitError(totalFiles, fileLimits.maxPerConversation), fileSizeError = computeFileSizeError(files, fileLimits.maxFileSizeBytes), @@ -438,6 +440,13 @@ class AttachmentViewModel @Inject constructor( } } + private fun computeTextSelectionLimitError(count: Int): String? = + if (count >= TextSelectionStore.MAX_SELECTIONS) { + context.getString(R.string.duckAiTextSelectionLimitReached, TextSelectionStore.MAX_SELECTIONS) + } else { + null + } + private fun computeImageLimitError( currentCount: Int, totalImages: Int, diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt index 9c9e1034ac84..63b49a09c475 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/AttachmentView.kt @@ -241,7 +241,8 @@ class AttachmentView( syncPageContext(state) syncTextSelections(state) val errorMessage = effectiveLimitError( - imageLimitError = state.imageLimitError + imageLimitError = state.textSelectionLimitError + ?: state.imageLimitError ?: state.fileLimitError ?: state.fileSizeError ?: state.filePageCountError diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index 841f810a6a25..46dcb477833a 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -55,6 +55,7 @@ Text selection + You can add up to %1$d text selections. Remove one to add another. Ask Duck.ai From 2315aee39ccc68547a9be14457b0583c21872a3a Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 9 Sep 2026 20:40:33 +0100 Subject: [PATCH 67/73] Handle null calling package --- .../duckchat/impl/contextual/RealDuckChatContextual.kt | 2 +- .../nativeinput/textselection/SelectedTextDuckAiActivity.kt | 5 ++++- .../duckchat/impl/contextual/RealDuckChatContextualTest.kt | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt index 223c3f6d79c8..0094b2d940e4 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt @@ -36,8 +36,8 @@ import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.store.DuckChatContextualDataStore -import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore +import com.duckduckgo.navigation.api.GlobalActivityStarter import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt index 5354b93080b9..b29ab6ee1c09 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt @@ -65,5 +65,8 @@ class SelectedTextDuckAiActivity : DuckDuckGoActivity() { return null } - private fun isExternalSelection(): Boolean = callingPackage != packageName + private fun isExternalSelection(): Boolean { + val sender = callingPackage ?: referrer?.host + return sender != null && sender != packageName + } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt index 3c0ff1909874..c0dfb5abed1e 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt @@ -22,8 +22,8 @@ import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.store.DuckChatContextualDataStore -import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore +import com.duckduckgo.navigation.api.GlobalActivityStarter import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test From 77cac63d3f27027c26d73cf16d694e6a0236b8e2 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 10 Sep 2026 00:24:32 +0100 Subject: [PATCH 68/73] Fix limit issue --- .../duckchat/impl/ui/AttachmentViewModel.kt | 21 ++++++++---- .../textselection/TextSelectionStore.kt | 17 ++++++++-- .../impl/ui/AttachmentViewModelTest.kt | 33 +++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index 00f26cc57662..1d7aeed902f4 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -119,6 +119,7 @@ class AttachmentViewModel @Inject constructor( private val _fileAttachments = MutableStateFlow>(emptyList()) private val _pageContextAttachment = MutableStateFlow(null) private val _textSelections = MutableStateFlow>(emptyList()) + private val _textSelectionLimitReached = MutableStateFlow(false) private val isDuckAiModeFlow: StateFlow = nativeInputStateProvider.state .map { it.inputContext != NativeInputState.InputContext.BROWSER } @@ -129,13 +130,19 @@ class AttachmentViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, DuckChatPixelSurface.ADDRESS_BAR) val attachmentState: StateFlow = combine( - combine(imageAttachments, _fileAttachments, _pageContextAttachment, _textSelections) { images, files, pageContext, selections -> - AttachmentLists(images, files, pageContext, selections) + combine( + imageAttachments, + _fileAttachments, + _pageContextAttachment, + _textSelections, + _textSelectionLimitReached, + ) { images, files, pageContext, selections, limitReached -> + AttachmentLists(images, files, pageContext, selections, limitReached) }, modelManager.modelState, combine(limitsHandler.conversationImagesSent, limitsHandler.conversationFilesUsed) { imgSent, filesUsed -> Pair(imgSent, filesUsed) }, isDuckAiModeFlow, - ) { (images, files, pageContext, selections), modelState, (conversationImagesSent, conversationFilesUsed), isDuckAiMode -> + ) { (images, files, pageContext, selections, selectionLimitReached), modelState, (conversationImagesSent, conversationFilesUsed), isDuckAiMode -> val conversationFilesSent = conversationFilesUsed.count val conversationFileSizeSentBytes = conversationFilesUsed.sizeBytes val model = modelState.models.find { it.id == modelState.selectedModelId } @@ -153,7 +160,7 @@ class AttachmentViewModel @Inject constructor( files = files, pageContext = pageContext, textSelections = selections, - textSelectionLimitError = computeTextSelectionLimitError(selections.size), + textSelectionLimitError = computeTextSelectionLimitError(selectionLimitReached), imageLimitError = computeImageLimitError(currentImageCount, totalImages, imageLimits), fileLimitError = computeFileLimitError(totalFiles, fileLimits.maxPerConversation), fileSizeError = computeFileSizeError(files, fileLimits.maxFileSizeBytes), @@ -358,6 +365,7 @@ class AttachmentViewModel @Inject constructor( val files: List, val pageContext: PageContextAttachment?, val textSelections: List, + val textSelectionLimitReached: Boolean, ) fun bindTextSelections(tabId: String, textSelection: String?) { @@ -365,6 +373,7 @@ class AttachmentViewModel @Inject constructor( textSelection?.let { textSelectionStore.add(tabId, it, url = "") } textSelectionsJob?.cancel() textSelectionsJob = viewModelScope.launch { + launch { textSelectionStore.limitReached(tabId).collect { _textSelectionLimitReached.value = it } } textSelectionStore.selections(tabId).collect { selections -> _textSelections.value = selections.map { TextSelectionAttachment(id = it.id, text = it.text) } } @@ -440,8 +449,8 @@ class AttachmentViewModel @Inject constructor( } } - private fun computeTextSelectionLimitError(count: Int): String? = - if (count >= TextSelectionStore.MAX_SELECTIONS) { + private fun computeTextSelectionLimitError(limitReached: Boolean): String? = + if (limitReached) { context.getString(R.string.duckAiTextSelectionLimitReached, TextSelectionStore.MAX_SELECTIONS) } else { null diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt index d57b9b41a283..a3359808809f 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt @@ -36,6 +36,7 @@ data class TextSelection( interface TextSelectionStore { fun selections(tabId: String): StateFlow> + fun limitReached(tabId: String): StateFlow fun add(tabId: String, text: String, url: String): Boolean fun consume(tabId: String): List fun remove(tabId: String, id: String) @@ -50,27 +51,39 @@ interface TextSelectionStore { class RealTextSelectionStore @Inject constructor() : TextSelectionStore { private val selections = ConcurrentHashMap>>() + private val limitReached = ConcurrentHashMap>() override fun selections(tabId: String): StateFlow> = getFlow(tabId) + override fun limitReached(tabId: String): StateFlow = getLimitFlow(tabId) + override fun add(tabId: String, text: String, url: String): Boolean { val selection = getTextSelection(text, url) ?: return false val selections = getFlow(tabId).updateAndGet { existing -> val isDuplicate = existing.any { it.text == selection.text } if (isDuplicate || existing.size >= TextSelectionStore.MAX_SELECTIONS) existing else existing + selection } - return selections.any { it.text == selection.text } + val isAttached = selections.any { it.text == selection.text } + if (!isAttached) getLimitFlow(tabId).value = true + return isAttached } - override fun consume(tabId: String): List = getFlow(tabId).getAndUpdate { emptyList() } + override fun consume(tabId: String): List { + getLimitFlow(tabId).value = false + return getFlow(tabId).getAndUpdate { emptyList() } + } override fun remove(tabId: String, id: String) { + getLimitFlow(tabId).value = false getFlow(tabId).update { current -> current.filterNot { it.id == id } } } private fun getFlow(tabId: String): MutableStateFlow> = selections.computeIfAbsent(tabId) { MutableStateFlow(emptyList()) } + private fun getLimitFlow(tabId: String): MutableStateFlow = + limitReached.computeIfAbsent(tabId) { MutableStateFlow(false) } + private fun getTextSelection(text: String, url: String): TextSelection? { val trimmed = text.trim() if (trimmed.isEmpty()) return null diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt index d490a2c280b7..0c427165e076 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt @@ -48,6 +48,7 @@ import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachmentProcessor import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -89,6 +90,8 @@ class AttachmentViewModelTest { private val context: Context = mock().also { whenever(it.getString(com.duckduckgo.duckchat.impl.R.string.duckChatImageAttachmentLimitPerConversation, 5)) .thenReturn("Conversation limit reached") + whenever(it.getString(com.duckduckgo.duckchat.impl.R.string.duckAiTextSelectionLimitReached, TextSelectionStore.MAX_SELECTIONS)) + .thenReturn("Selection limit reached") whenever(it.getString(com.duckduckgo.duckchat.impl.R.string.duckChatImageAttachmentLimitPerMessage, 3)) .thenReturn("Per-message limit reached") whenever(it.getString(com.duckduckgo.duckchat.impl.R.string.duckChatFileAttachmentLimitPerConversation, 2)) @@ -284,6 +287,36 @@ class AttachmentViewModelTest { assertTrue(viewModel.attachmentState.value.hasAttachments) } + @Test + fun whenAtSelectionLimitThenNoLimitErrorUntilAnotherIsAttempted() = runTest { + viewModel.bindTextSelections("tab-1", textSelection = null) + repeat(TextSelectionStore.MAX_SELECTIONS) { textSelectionStore.add("tab-1", "selection $it", "https://example.com") } + + assertNull(viewModel.attachmentState.value.textSelectionLimitError) + } + + @Test + fun whenSelectionRefusedAtLimitThenLimitErrorShown() = runTest { + viewModel.bindTextSelections("tab-1", textSelection = null) + repeat(TextSelectionStore.MAX_SELECTIONS) { textSelectionStore.add("tab-1", "selection $it", "https://example.com") } + + textSelectionStore.add("tab-1", "one too many", "https://example.com") + + assertEquals("Selection limit reached", viewModel.attachmentState.value.textSelectionLimitError) + } + + @Test + fun whenSelectionRemovedAfterLimitThenLimitErrorCleared() = runTest { + viewModel.bindTextSelections("tab-1", textSelection = null) + repeat(TextSelectionStore.MAX_SELECTIONS) { textSelectionStore.add("tab-1", "selection $it", "https://example.com") } + textSelectionStore.add("tab-1", "one too many", "https://example.com") + val target = textSelectionStore.selections("tab-1").value.first() + + viewModel.removeTextSelection(target.id) + + assertNull(viewModel.attachmentState.value.textSelectionLimitError) + } + @Test fun whenOnlyTextSelectionsAttachedThenHasNoStandaloneAttachments() = runTest { viewModel.bindTextSelections("tab-1", "selected words") From b6612b5052c4cc6efebbd699b8ea6df1dbd8adf9 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 10 Sep 2026 00:39:45 +0100 Subject: [PATCH 69/73] Add store test --- .../textselection/TextSelectionStoreTest.kt | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStoreTest.kt diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStoreTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStoreTest.kt new file mode 100644 index 000000000000..8fe56b390301 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStoreTest.kt @@ -0,0 +1,135 @@ +/* + * 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.impl.ui.nativeinput.textselection + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TextSelectionStoreTest { + + private val testee = RealTextSelectionStore() + + @Test + fun whenSelectionAddedThenItIsStoredAgainstThatTab() { + assertTrue(testee.add(TAB, "selected words", URL)) + + val selections = testee.selections(TAB).value + assertEquals(1, selections.size) + assertEquals("selected words", selections.first().text) + assertEquals(URL, selections.first().url) + } + + @Test + fun whenSelectionAddedThenTextIsTrimmed() { + testee.add(TAB, " padded ", URL) + + assertEquals("padded", testee.selections(TAB).value.first().text) + } + + @Test + fun whenBlankSelectionAddedThenNothingIsStored() { + assertFalse(testee.add(TAB, " ", URL)) + + assertTrue(testee.selections(TAB).value.isEmpty()) + } + + @Test + fun whenSameTextAddedTwiceThenItIsStoredOnce() { + testee.add(TAB, "selected words", URL) + + assertTrue(testee.add(TAB, "selected words", URL)) + + assertEquals(1, testee.selections(TAB).value.size) + } + + @Test + fun whenSelectionsForDifferentTabsThenTheyAreKeptApart() { + testee.add(TAB, "first tab", URL) + testee.add("other-tab", "second tab", URL) + + assertEquals(listOf("first tab"), testee.selections(TAB).value.map { it.text }) + assertEquals(listOf("second tab"), testee.selections("other-tab").value.map { it.text }) + } + + @Test + fun whenLimitNotYetExceededThenLimitNotReached() { + fillToLimit() + + assertFalse(testee.limitReached(TAB).value) + } + + @Test + fun whenAddRefusedAtLimitThenLimitReachedAndNothingStored() { + fillToLimit() + + assertFalse(testee.add(TAB, "one too many", URL)) + + assertTrue(testee.limitReached(TAB).value) + assertEquals(TextSelectionStore.MAX_SELECTIONS, testee.selections(TAB).value.size) + } + + @Test + fun whenSelectionRemovedAfterLimitThenLimitReachedCleared() { + fillToLimit() + testee.add(TAB, "one too many", URL) + + testee.remove(TAB, testee.selections(TAB).value.first().id) + + assertFalse(testee.limitReached(TAB).value) + } + + @Test + fun whenConsumedThenSelectionsReturnedAndCleared() { + testee.add(TAB, "first", URL) + testee.add(TAB, "second", URL) + + val consumed = testee.consume(TAB) + + assertEquals(listOf("first", "second"), consumed.map { it.text }) + assertTrue(testee.selections(TAB).value.isEmpty()) + } + + @Test + fun whenConsumedThenLimitReachedCleared() { + fillToLimit() + testee.add(TAB, "one too many", URL) + + testee.consume(TAB) + + assertFalse(testee.limitReached(TAB).value) + } + + @Test + fun whenUnknownIdRemovedThenNothingChanges() { + testee.add(TAB, "selected words", URL) + + testee.remove(TAB, "not-a-real-id") + + assertEquals(1, testee.selections(TAB).value.size) + } + + private fun fillToLimit() { + repeat(TextSelectionStore.MAX_SELECTIONS) { testee.add(TAB, "selection $it", URL) } + } + + private companion object { + const val TAB = "tab-1" + const val URL = "https://example.com" + } +} From c663ffe5dd96421dd59f4eaf5eeddfb7d9a7e875 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 10 Sep 2026 00:42:27 +0100 Subject: [PATCH 70/73] Rename store to repository --- .../DuckChatContextualEntryViewModel.kt | 10 ++--- .../impl/contextual/RealDuckChatContextual.kt | 10 ++--- .../duckchat/impl/ui/AttachmentViewModel.kt | 16 ++++---- ...ionStore.kt => TextSelectionRepository.kt} | 6 +-- .../DuckChatContextualEntryViewModelTest.kt | 40 +++++++++---------- .../contextual/RealDuckChatContextualTest.kt | 6 +-- .../impl/ui/AttachmentViewModelTest.kt | 24 +++++------ ...Test.kt => TextSelectionRepositoryTest.kt} | 8 ++-- 8 files changed, 60 insertions(+), 60 deletions(-) rename duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/{TextSelectionStore.kt => TextSelectionRepository.kt} (93%) rename duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/{TextSelectionStoreTest.kt => TextSelectionRepositoryTest.kt} (92%) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt index e9a82db37d16..18478af6f15e 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModel.kt @@ -25,7 +25,7 @@ import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionRepository import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -48,7 +48,7 @@ class DuckChatContextualEntryViewModel @Inject constructor( private val contextualEntryPromptStore: ContextualEntryPromptStore, private val duckChatPixels: DuckChatPixels, private val modelManager: DuckAiModelManager, - private val textSelectionStore: TextSelectionStore, + private val textSelectionRepository: TextSelectionRepository, private val selectionPayloadBuilder: TextSelectionPayloadBuilder, ) : ViewModel() { @@ -86,7 +86,7 @@ class DuckChatContextualEntryViewModel @Inject constructor( fun start(tabId: String) { this.tabId = tabId duckChatPixels.reportContextualFloatingInputShown() - textSelectionStore.selections(tabId) + textSelectionRepository.selections(tabId) .onEach { selections -> _viewState.update { it.copy(textSelectionCount = selections.size) } } .launchIn(viewModelScope) } @@ -148,7 +148,7 @@ class DuckChatContextualEntryViewModel @Inject constructor( } private fun submit(prompt: NativeInputPrompt) { - val selectionsJson = prompt.selectionsJson ?: selectionPayloadBuilder.toJson(textSelectionStore.consume(tabId)) + val selectionsJson = prompt.selectionsJson ?: selectionPayloadBuilder.toJson(textSelectionRepository.consume(tabId)) contextualEntryPromptStore.store( ContextualEntryPrompt( tabId = tabId, @@ -161,7 +161,7 @@ class DuckChatContextualEntryViewModel @Inject constructor( commandChannel.trySend(Command.HandOffToSheet) } - private fun hasTextSelections(): Boolean = textSelectionStore.selections(tabId).value.isNotEmpty() + private fun hasTextSelections(): Boolean = textSelectionRepository.selections(tabId).value.isNotEmpty() private fun attach(serializedPageContext: String) { val json = runCatching { JSONObject(serializedPageContext) }.getOrNull() ?: return diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt index 0094b2d940e4..6942f040d0a9 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt @@ -36,7 +36,7 @@ import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.store.DuckChatContextualDataStore -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionRepository import com.duckduckgo.navigation.api.GlobalActivityStarter import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -52,7 +52,7 @@ class RealDuckChatContextual @Inject constructor( private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, private val contextualEntryPromptStore: ContextualEntryPromptStore, private val globalActivityStarter: GlobalActivityStarter, - private val textSelectionStore: TextSelectionStore, + private val textSelectionRepository: TextSelectionRepository, ) : DuckChatContextual { override suspend fun launch( @@ -66,13 +66,13 @@ class RealDuckChatContextual @Inject constructor( showChatSurface() return } - textSelection?.let { textSelectionStore.add(sourceTabId, it, sourceUrl.orEmpty()) } + textSelection?.let { textSelectionRepository.add(sourceTabId, it, sourceUrl.orEmpty()) } if (hasChatInProgress(sourceTabId)) { // The sheet would reopen the existing chat for this tab, so skip the entry menu and open it directly. showChatSurface() return } - if (textSelectionStore.selections(sourceTabId).value.isNotEmpty()) { + if (textSelectionRepository.selections(sourceTabId).value.isNotEmpty()) { showEntryDialog(anchor, sourceTabId, showChatSurface) return } @@ -144,7 +144,7 @@ class RealDuckChatContextual @Inject constructor( } else { popup.onMenuItemClicked(askItem) { duckChatPixels.reportContextualAddressBarMenuAskAboutPageSelected() - textSelectionStore.consume(sourceTabId) + textSelectionRepository.consume(sourceTabId) showEntryDialog(anchor, sourceTabId, onAskAboutPage) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt index 1d7aeed902f4..cc699304d242 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/AttachmentViewModel.kt @@ -47,7 +47,7 @@ import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachmentProcessor import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionRepository import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -77,7 +77,7 @@ class AttachmentViewModel @Inject constructor( private val appBuildConfig: AppBuildConfig, nativeInputStateProvider: NativeInputStateProvider, private val duckChatPixels: DuckChatPixels, - private val textSelectionStore: TextSelectionStore, + private val textSelectionRepository: TextSelectionRepository, private val textSelectionPayloadBuilder: TextSelectionPayloadBuilder, ) : ViewModel() { @@ -370,23 +370,23 @@ class AttachmentViewModel @Inject constructor( fun bindTextSelections(tabId: String, textSelection: String?) { textSelectionsTabId = tabId - textSelection?.let { textSelectionStore.add(tabId, it, url = "") } + textSelection?.let { textSelectionRepository.add(tabId, it, url = "") } textSelectionsJob?.cancel() textSelectionsJob = viewModelScope.launch { - launch { textSelectionStore.limitReached(tabId).collect { _textSelectionLimitReached.value = it } } - textSelectionStore.selections(tabId).collect { selections -> + launch { textSelectionRepository.limitReached(tabId).collect { _textSelectionLimitReached.value = it } } + textSelectionRepository.selections(tabId).collect { selections -> _textSelections.value = selections.map { TextSelectionAttachment(id = it.id, text = it.text) } } } } fun removeTextSelection(id: String) { - textSelectionsTabId?.let { textSelectionStore.remove(it, id) } + textSelectionsTabId?.let { textSelectionRepository.remove(it, id) } } fun getTextSelectionsJson(): JSONArray? { val tabId = textSelectionsTabId ?: return null - return textSelectionPayloadBuilder.toJson(textSelectionStore.consume(tabId)) + return textSelectionPayloadBuilder.toJson(textSelectionRepository.consume(tabId)) } fun setPageContext(attachment: PageContextAttachment) { @@ -451,7 +451,7 @@ class AttachmentViewModel @Inject constructor( private fun computeTextSelectionLimitError(limitReached: Boolean): String? = if (limitReached) { - context.getString(R.string.duckAiTextSelectionLimitReached, TextSelectionStore.MAX_SELECTIONS) + context.getString(R.string.duckAiTextSelectionLimitReached, TextSelectionRepository.MAX_SELECTIONS) } else { null } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepository.kt similarity index 93% rename from duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt rename to duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepository.kt index a3359808809f..d521f324e225 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStore.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepository.kt @@ -34,7 +34,7 @@ data class TextSelection( val url: String, ) -interface TextSelectionStore { +interface TextSelectionRepository { fun selections(tabId: String): StateFlow> fun limitReached(tabId: String): StateFlow fun add(tabId: String, text: String, url: String): Boolean @@ -48,7 +48,7 @@ interface TextSelectionStore { @SingleInstanceIn(AppScope::class) @ContributesBinding(AppScope::class) -class RealTextSelectionStore @Inject constructor() : TextSelectionStore { +class RealTextSelectionRepository @Inject constructor() : TextSelectionRepository { private val selections = ConcurrentHashMap>>() private val limitReached = ConcurrentHashMap>() @@ -61,7 +61,7 @@ class RealTextSelectionStore @Inject constructor() : TextSelectionStore { val selection = getTextSelection(text, url) ?: return false val selections = getFlow(tabId).updateAndGet { existing -> val isDuplicate = existing.any { it.text == selection.text } - if (isDuplicate || existing.size >= TextSelectionStore.MAX_SELECTIONS) existing else existing + selection + if (isDuplicate || existing.size >= TextSelectionRepository.MAX_SELECTIONS) existing else existing + selection } val isAttached = selections.any { it.text == selection.text } if (!isAttached) getLimitFlow(tabId).value = true diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt index c4ccd2fa1c52..b7e8384efc1f 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryViewModelTest.kt @@ -24,9 +24,9 @@ import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionPayloadBuilder -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionRepository import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionRepository import kotlinx.coroutines.test.runTest import org.json.JSONArray import org.json.JSONObject @@ -51,12 +51,12 @@ class DuckChatContextualEntryViewModelTest { private val store: ContextualEntryPromptStore = mock() private val duckChatPixels: DuckChatPixels = mock() private val modelManager: DuckAiModelManager = mock() - private val textSelectionStore = RealTextSelectionStore() + private val textSelectionRepository = RealTextSelectionRepository() private val viewModel = DuckChatContextualEntryViewModel( store, duckChatPixels, modelManager, - textSelectionStore, + textSelectionRepository, RealTextSelectionPayloadBuilder(RuntimeEnvironment.getApplication(), object : DuckAiHostProvider {}), ) @@ -301,7 +301,7 @@ class DuckChatContextualEntryViewModelTest { @Test fun whenTextSelectionAttachedThenPageContextNotAttached() = runTest { viewModel.start("tab-1") - textSelectionStore.add("tab-1", "selected words", "https://example.com") + textSelectionRepository.add("tab-1", "selected words", "https://example.com") viewModel.onPageContextReceived(validContext) @@ -312,8 +312,8 @@ class DuckChatContextualEntryViewModelTest { fun whenPromptSubmittedWithTextSelectionsThenSelectionsSentOnOwnKeyAndCleared() = runTest { viewModel.start("tab-1") viewModel.onPageContextReceived(validContext) - textSelectionStore.add("tab-1", "first selection", "https://example.com") - textSelectionStore.add("tab-1", "second selection", "https://example.com") + textSelectionRepository.add("tab-1", "first selection", "https://example.com") + textSelectionRepository.add("tab-1", "second selection", "https://example.com") viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) @@ -330,7 +330,7 @@ class DuckChatContextualEntryViewModelTest { assertEquals(2, first.getInt("wordCount")) assertEquals(15, first.getInt("fullContentLength")) assertFalse(first.getBoolean("truncated")) - assertTrue(textSelectionStore.selections("tab-1").value.isEmpty()) + assertTrue(textSelectionRepository.selections("tab-1").value.isEmpty()) } @Test @@ -367,7 +367,7 @@ class DuckChatContextualEntryViewModelTest { @Test fun whenSelectionHasNoSourcePageThenPayloadReportsDuckAi() = runTest { viewModel.start("tab-1") - textSelectionStore.add("tab-1", "from another app", "") + textSelectionRepository.add("tab-1", "from another app", "") viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) @@ -382,8 +382,8 @@ class DuckChatContextualEntryViewModelTest { @Test fun whenSelectionsComeFromDifferentPagesThenEachCarriesItsOwnUrl() = runTest { viewModel.start("tab-1") - textSelectionStore.add("tab-1", "from imdb", "https://imdb.com") - textSelectionStore.add("tab-1", "from wikipedia", "https://wikipedia.org") + textSelectionRepository.add("tab-1", "from imdb", "https://imdb.com") + textSelectionRepository.add("tab-1", "from wikipedia", "https://wikipedia.org") viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) @@ -401,7 +401,7 @@ class DuckChatContextualEntryViewModelTest { fun whenSelectionExceedsMaxContentLengthThenTruncatedButSizeReported() = runTest { val long = "word ".repeat(3000) viewModel.start("tab-1") - textSelectionStore.add("tab-1", long, "https://example.com") + textSelectionRepository.add("tab-1", long, "https://example.com") viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) @@ -420,20 +420,20 @@ class DuckChatContextualEntryViewModelTest { @Test fun whenSelectionsExceedMaxThenExtrasDropped() { repeat( - TextSelectionStore.MAX_SELECTIONS + 2, - ) { index -> textSelectionStore.add("tab-1", "selection $index", "https://example.com") } + TextSelectionRepository.MAX_SELECTIONS + 2, + ) { index -> textSelectionRepository.add("tab-1", "selection $index", "https://example.com") } - assertEquals(TextSelectionStore.MAX_SELECTIONS, textSelectionStore.consume("tab-1").size) + assertEquals(TextSelectionRepository.MAX_SELECTIONS, textSelectionRepository.consume("tab-1").size) } @Test fun whenSelectionRemovedThenDroppedFromStore() { - textSelectionStore.add("tab-1", "keep me", "https://example.com") - textSelectionStore.add("tab-1", "remove me", "https://example.com") - val target = textSelectionStore.selections("tab-1").value.last() + textSelectionRepository.add("tab-1", "keep me", "https://example.com") + textSelectionRepository.add("tab-1", "remove me", "https://example.com") + val target = textSelectionRepository.selections("tab-1").value.last() - textSelectionStore.remove("tab-1", target.id) + textSelectionRepository.remove("tab-1", target.id) - assertEquals(listOf("keep me"), textSelectionStore.consume("tab-1").map { it.text }) + assertEquals(listOf("keep me"), textSelectionRepository.consume("tab-1").map { it.text }) } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt index c0dfb5abed1e..a4f9bfba401d 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextualTest.kt @@ -22,7 +22,7 @@ import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.store.DuckChatContextualDataStore -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionRepository import com.duckduckgo.navigation.api.GlobalActivityStarter import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -42,7 +42,7 @@ class RealDuckChatContextualTest { private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private val contextualEntryPromptStore = RealContextualEntryPromptStore() private val globalActivityStarter: GlobalActivityStarter = mock() - private val textSelectionStore = RealTextSelectionStore() + private val textSelectionRepository = RealTextSelectionRepository() private val anchor: View = mock() private val testee = RealDuckChatContextual( @@ -55,7 +55,7 @@ class RealDuckChatContextualTest { duckDuckGoUrlDetector, contextualEntryPromptStore, globalActivityStarter, - textSelectionStore, + textSelectionRepository, ) @Test diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt index 0c427165e076..33a179e9724a 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/AttachmentViewModelTest.kt @@ -46,9 +46,9 @@ import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedFile import com.duckduckgo.duckchat.impl.ui.nativeinput.edit.SubmittedImage import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachment import com.duckduckgo.duckchat.impl.ui.nativeinput.file.FileAttachmentProcessor -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionRepository import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder -import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionStore +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -90,7 +90,7 @@ class AttachmentViewModelTest { private val context: Context = mock().also { whenever(it.getString(com.duckduckgo.duckchat.impl.R.string.duckChatImageAttachmentLimitPerConversation, 5)) .thenReturn("Conversation limit reached") - whenever(it.getString(com.duckduckgo.duckchat.impl.R.string.duckAiTextSelectionLimitReached, TextSelectionStore.MAX_SELECTIONS)) + whenever(it.getString(com.duckduckgo.duckchat.impl.R.string.duckAiTextSelectionLimitReached, TextSelectionRepository.MAX_SELECTIONS)) .thenReturn("Selection limit reached") whenever(it.getString(com.duckduckgo.duckchat.impl.R.string.duckChatImageAttachmentLimitPerMessage, 3)) .thenReturn("Per-message limit reached") @@ -129,7 +129,7 @@ class AttachmentViewModelTest { private lateinit var viewModel: AttachmentViewModel - private val textSelectionStore = RealTextSelectionStore() + private val textSelectionRepository = RealTextSelectionRepository() private val textSelectionPayloadBuilder: TextSelectionPayloadBuilder = mock() @Before @@ -144,7 +144,7 @@ class AttachmentViewModelTest { appBuildConfig = appBuildConfig, nativeInputStateProvider = nativeInputStateStore, duckChatPixels = duckChatPixels, - textSelectionStore = textSelectionStore, + textSelectionRepository = textSelectionRepository, textSelectionPayloadBuilder = textSelectionPayloadBuilder, ) } @@ -290,7 +290,7 @@ class AttachmentViewModelTest { @Test fun whenAtSelectionLimitThenNoLimitErrorUntilAnotherIsAttempted() = runTest { viewModel.bindTextSelections("tab-1", textSelection = null) - repeat(TextSelectionStore.MAX_SELECTIONS) { textSelectionStore.add("tab-1", "selection $it", "https://example.com") } + repeat(TextSelectionRepository.MAX_SELECTIONS) { textSelectionRepository.add("tab-1", "selection $it", "https://example.com") } assertNull(viewModel.attachmentState.value.textSelectionLimitError) } @@ -298,9 +298,9 @@ class AttachmentViewModelTest { @Test fun whenSelectionRefusedAtLimitThenLimitErrorShown() = runTest { viewModel.bindTextSelections("tab-1", textSelection = null) - repeat(TextSelectionStore.MAX_SELECTIONS) { textSelectionStore.add("tab-1", "selection $it", "https://example.com") } + repeat(TextSelectionRepository.MAX_SELECTIONS) { textSelectionRepository.add("tab-1", "selection $it", "https://example.com") } - textSelectionStore.add("tab-1", "one too many", "https://example.com") + textSelectionRepository.add("tab-1", "one too many", "https://example.com") assertEquals("Selection limit reached", viewModel.attachmentState.value.textSelectionLimitError) } @@ -308,9 +308,9 @@ class AttachmentViewModelTest { @Test fun whenSelectionRemovedAfterLimitThenLimitErrorCleared() = runTest { viewModel.bindTextSelections("tab-1", textSelection = null) - repeat(TextSelectionStore.MAX_SELECTIONS) { textSelectionStore.add("tab-1", "selection $it", "https://example.com") } - textSelectionStore.add("tab-1", "one too many", "https://example.com") - val target = textSelectionStore.selections("tab-1").value.first() + repeat(TextSelectionRepository.MAX_SELECTIONS) { textSelectionRepository.add("tab-1", "selection $it", "https://example.com") } + textSelectionRepository.add("tab-1", "one too many", "https://example.com") + val target = textSelectionRepository.selections("tab-1").value.first() viewModel.removeTextSelection(target.id) @@ -355,7 +355,7 @@ class AttachmentViewModelTest { @Test fun whenBoundToADifferentTabThenOtherTabsSelectionsAreNotShown() = runTest { - textSelectionStore.add("tab-other", "not mine", "https://example.com") + textSelectionRepository.add("tab-other", "not mine", "https://example.com") viewModel.bindTextSelections("tab-1", textSelection = null) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStoreTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepositoryTest.kt similarity index 92% rename from duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStoreTest.kt rename to duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepositoryTest.kt index 8fe56b390301..ffcc9255729c 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionStoreTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepositoryTest.kt @@ -21,9 +21,9 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class TextSelectionStoreTest { +class TextSelectionRepositoryTest { - private val testee = RealTextSelectionStore() + private val testee = RealTextSelectionRepository() @Test fun whenSelectionAddedThenItIsStoredAgainstThatTab() { @@ -81,7 +81,7 @@ class TextSelectionStoreTest { assertFalse(testee.add(TAB, "one too many", URL)) assertTrue(testee.limitReached(TAB).value) - assertEquals(TextSelectionStore.MAX_SELECTIONS, testee.selections(TAB).value.size) + assertEquals(TextSelectionRepository.MAX_SELECTIONS, testee.selections(TAB).value.size) } @Test @@ -125,7 +125,7 @@ class TextSelectionStoreTest { } private fun fillToLimit() { - repeat(TextSelectionStore.MAX_SELECTIONS) { testee.add(TAB, "selection $it", URL) } + repeat(TextSelectionRepository.MAX_SELECTIONS) { testee.add(TAB, "selection $it", URL) } } private companion object { From 1146d7d16099c49c5cf21283a871200c8f965788 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 10 Sep 2026 01:27:09 +0100 Subject: [PATCH 71/73] Hide suggestions for more than one attachment --- .../contextual/suggestions/ContextualSuggestionsViewModel.kt | 1 + .../suggestions/ContextualSuggestionsViewModelTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt index eba9cd98a6e9..bce285daa150 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModel.kt @@ -145,6 +145,7 @@ class ContextualSuggestionsViewModel @Inject constructor( } private fun visibleSuggestions(): List { + if (textSelectionCount > 1) return emptyList() if (textSelectionCount > 0) return resolvedSuggestions.take(MAX_TEXT_SELECTION_SUGGESTIONS) val capacity = (maxSuggestedPrompts - reservedQuickActionSlots).coerceAtLeast(0) if (resolvedSuggestions.size <= capacity) return resolvedSuggestions diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt index d67fc75de3c5..dbfc9b3544e4 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/suggestions/ContextualSuggestionsViewModelTest.kt @@ -457,7 +457,7 @@ class ContextualSuggestionsViewModelTest { } @Test - fun whenMoreThanOneTextSelectionAttachedThenSelectionSuggestionsStillShown() = runTest { + fun whenMoreThanOneTextSelectionAttachedThenSelectionSuggestionsHidden() = runTest { val selectionSuggestions = listOf( ContextualSuggestedPrompt("summarize-selection", "Summarize this selection", "Summarize this selection.", "summary"), ) @@ -466,6 +466,6 @@ class ContextualSuggestionsViewModelTest { viewModel.onTextSelectionCountChanged(2) - assertEquals(selectionSuggestions, viewModel.viewState.value.suggestions) + assertTrue(viewModel.viewState.value.suggestions.isEmpty()) } } From e2f505670e840f4ddb074c853ab123e0bef27ea5 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 10 Sep 2026 15:55:09 +0100 Subject: [PATCH 72/73] Rename isContextual to forceLaunchContextual --- .../com/duckduckgo/app/browser/navigation/AppBrowserNav.kt | 6 +++--- .../src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt | 4 ++-- .../nativeinput/textselection/SelectedTextDuckAiActivity.kt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt index d4584bac14a6..b00be1d1e92c 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/navigation/AppBrowserNav.kt @@ -56,15 +56,15 @@ class AppBrowserNav @Inject constructor() : BrowserNav { hasSessionActive: Boolean, duckChatUrl: String, forceImageGeneration: Boolean, - isContextual: Boolean, + forceLaunchContextual: Boolean, textSelection: String?, ): Intent { - val isExternal = textSelection != null && !isContextual + val isExternal = textSelection != null && !forceLaunchContextual return BrowserActivity.intent( context = context, launchSource = if (isExternal) SelectedTextSearch else InAppNavigation, openDuckChat = true, - duckChatContextual = isContextual, + duckChatContextual = forceLaunchContextual, duckChatUrl = duckChatUrl, duckChatSessionActive = hasSessionActive, duckChatForceImageGeneration = forceImageGeneration, diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt index d4440652bce5..5c1ee4e0db10 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/BrowserNav.kt @@ -37,7 +37,7 @@ interface BrowserNav { * Returns an Intent that opens Duck.ai full screen in a new tab. * * @param forceImageGeneration when true, the new tab's native input preselects the image-generation tool. - * @param isContextual when true, opens the contextual Duck.ai flow instead of a full screen tab. + * @param forceLaunchContextual when true, opens the contextual Duck.ai flow instead of a full screen tab. * @param textSelection selected page text to attach to the contextual chat, if any. */ fun openDuckChat( @@ -45,7 +45,7 @@ interface BrowserNav { hasSessionActive: Boolean = false, duckChatUrl: String, forceImageGeneration: Boolean = false, - isContextual: Boolean = false, + forceLaunchContextual: Boolean = false, textSelection: String? = null, ): Intent fun closeDuckChat(context: Context): Intent diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt index b29ab6ee1c09..25cab1231ab5 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.kt @@ -47,7 +47,7 @@ class SelectedTextDuckAiActivity : DuckDuckGoActivity() { browserNav.openDuckChat( this, duckChatUrl = duckChatInternal.getDuckChatUrl(query = "", autoPrompt = false), - isContextual = !isExternalSelection(), + forceLaunchContextual = !isExternalSelection(), textSelection = selection, ), ) From 3c6a38ed69e75f4f4e45aa69daf7689184f9bae8 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 17 Sep 2026 17:19:20 -0400 Subject: [PATCH 73/73] Fix test after rebase --- .../duckchat-impl/src/main/res/values/donottranslate.xml | 3 --- .../kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index 46dcb477833a..8d566cd0f301 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -44,9 +44,6 @@ Select Chats Chat Protection - - All Chats - Summarize this selection Summarize this selection. 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 a5739f461404..0b7d0446df4b 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 @@ -2046,7 +2046,7 @@ class RealDuckChatTest { coroutineRule.testScope.advanceUntilIdle() verify(mockDuckAiModelManager, never()).selectModel(any()) - verify(mockBrowserNav).openDuckChat(any(), any(), any(), forceImageGeneration = eq(true)) + verify(mockBrowserNav).openDuckChat(any(), any(), any(), eq(true), any(), anyOrNull()) verify(mockContext).startActivity(mockIntent) } @@ -2062,7 +2062,7 @@ class RealDuckChatTest { coroutineRule.testScope.advanceUntilIdle() verify(mockDuckAiModelManager).selectModel(capable) - verify(mockBrowserNav).openDuckChat(any(), any(), any(), forceImageGeneration = eq(true)) + verify(mockBrowserNav).openDuckChat(any(), any(), any(), eq(true), any(), anyOrNull()) verify(mockContext).startActivity(mockIntent) } @@ -2078,7 +2078,7 @@ class RealDuckChatTest { coroutineRule.testScope.advanceUntilIdle() verify(mockDuckAiModelManager, never()).selectModel(any()) - verify(mockBrowserNav).openDuckChat(any(), any(), any(), forceImageGeneration = eq(false)) + verify(mockBrowserNav).openDuckChat(any(), any(), any(), eq(false), any(), anyOrNull()) verify(mockContext).startActivity(mockIntent) }