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..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,6 +846,12 @@ open class BrowserActivity : DuckDuckGoActivity() { } if (intent.getBooleanExtra(OPEN_DUCK_CHAT, false)) { + 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 -> @@ -1106,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. @@ -1315,6 +1324,8 @@ open class BrowserActivity : DuckDuckGoActivity() { interstitialScreen: Boolean = false, openExistingTabId: String? = null, openDuckChat: Boolean = false, + duckChatContextual: Boolean = false, + duckChatTextSelection: String? = null, closeDuckChat: Boolean = false, duckChatUrl: String? = null, duckChatSessionActive: Boolean = false, @@ -1332,6 +1343,8 @@ 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(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) intent.putExtra(DUCK_CHAT_URL, duckChatUrl) @@ -1369,6 +1382,8 @@ 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 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" 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..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 = { @@ -1434,7 +1435,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 +1466,9 @@ class BrowserTabFragment : } }, ) + if (selectionsJson != null) { + put("selections", selectionsJson) + } }, ), ) @@ -2443,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( @@ -4027,6 +4031,12 @@ class BrowserTabFragment : ) } + fun launchContextualDuckAi(textSelection: String? = null) { + viewLifecycleOwner.lifecycleScope.launch(dispatchers.main()) { + duckChatContextual.launch(tabId, webView?.url, webView, textSelection) { showDuckChatContextualSheet(tabId) } + } + } + private fun showDuckChatContextualSheet(tabId: String) { binding.duckAiContextualFragmentContainer.show() 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..20ce9f5deb72 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,15 @@ class DuckDuckGoWebView : isNestedScrollingEnabled = true } + override fun startActionMode(callback: ActionMode.Callback?): ActionMode? = + super.startActionMode(decorate(callback)) + + override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? = + super.startActionMode(decorate(callback), type) + + private fun decorate(callback: ActionMode.Callback?): ActionMode.Callback? = + if (::duckAiTextSelectionDecorator.isInitialized) duckAiTextSelectionDecorator.decorate(callback, pageUrl = url) else callback + override fun onAttachedToWindow() { AndroidSupportInjection.inject(this) super.onAttachedToWindow() 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..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 @@ -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, ) + textSelection?.let { bindTextSelections(tabId, it) } } applyWindowChrome(widgetView, isBottom) 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..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 @@ -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,14 +56,19 @@ class AppBrowserNav @Inject constructor() : BrowserNav { hasSessionActive: Boolean, duckChatUrl: String, forceImageGeneration: Boolean, + forceLaunchContextual: Boolean, + textSelection: String?, ): Intent { + val isExternal = textSelection != null && !forceLaunchContextual return BrowserActivity.intent( context = context, - launchSource = InAppNavigation, + launchSource = if (isExternal) SelectedTextSearch else InAppNavigation, openDuckChat = true, + duckChatContextual = forceLaunchContextual, duckChatUrl = duckChatUrl, duckChatSessionActive = hasSessionActive, duckChatForceImageGeneration = forceImageGeneration, + duckChatTextSelection = textSelection, ) } 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..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 @@ -424,7 +424,7 @@ class RealNativeInputManagerTest { callbacks = NativeInputCallbacks( onSearchTextChanged = {}, onSearchSubmitted = {}, - onDuckAiChatSubmitted = { _, _, _, _, _, _ -> }, + onDuckAiChatSubmitted = { _, _, _, _, _, _, _ -> }, onChatSuggestionSelected = {}, onDuckAiQuerySubmitted = onDuckAiQuerySubmitted, onClearAutocomplete = {}, 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..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,12 +37,16 @@ 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 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( context: Context, hasSessionActive: Boolean = false, duckChatUrl: String, forceImageGeneration: Boolean = false, + forceLaunchContextual: Boolean = false, + textSelection: String? = null, ): Intent fun closeDuckChat(context: Context): Intent 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..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 @@ -70,6 +70,11 @@ interface DuckAiFeatureState { */ val showContextualMode: StateFlow + /** + * Indicates whether the "Ask Duck.ai" text selection menu item should be shown. + */ + val showTextSelectionAction: StateFlow + /** * Indicates whether Duck.ai should be used as digital assistant */ 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..e7d11778168a --- /dev/null +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckAiTextSelectionDecorator.kt @@ -0,0 +1,33 @@ +/* + * 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. + * 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? +} 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, ) 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" /> + + + + + + + + + = _showContextualMode.asStateFlow() + override val showTextSelectionAction: StateFlow = _showTextSelectionAction.asStateFlow() + override val allowDuckAiAsDigitalAssistant: StateFlow = _allowDuckAiAsDigitalAssistant.asStateFlow() override val nativeInputFieldEnabled: StateFlow = _nativeInputFieldEnabled.asStateFlow() @@ -1155,6 +1158,12 @@ class RealDuckChat @Inject constructor( contextualMenuAllChatsEnabled = contextualSheetRedesignEnabled && duckChatFeature.contextualMenuAllChats().isEnabled() + _showTextSelectionAction.emit( + contextualSheetRedesignEnabled && + isContextualNativeInputEnabled && + duckChatFeature.duckAiTextSelectionAction().isEnabled(), + ) + isAutomaticContextAttachmentEnabled = isContextualModeEnabled && duckChatFeature.automaticContextAttachment() .isEnabled() && duckChatFeatureRepository.isAutomaticPageContextAttachmentUserSettingEnabled() 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/ContextualNativeInputManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/ContextualNativeInputManager.kt index fa7575d76b9a..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 { @@ -219,6 +220,7 @@ class RealContextualNativeInputManager @Inject constructor( onVoiceSearchRequested: () -> Unit, ) { widget.configureContextual(tabId) + widget.bindTextSelections(tabId, textSelection = null) widget.bindChatIdSource(chatIdFlow) widget.bindModelPickerEnabledSource(modelPickerEnabled) widget.hideMainButtons() @@ -241,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() @@ -252,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/DuckChatContextualEntryDialog.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualEntryDialog.kt index d4d714693a0d..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 @@ -223,6 +223,7 @@ class DuckChatContextualEntryDialog : DuckDuckGoBottomSheetDialogFragment() { } else { binding.entryNativeInputWidget.clearPageContext() } + binding.entrySuggestionsView.onTextSelectionCountChanged(state.textSelectionCount) updateQuickActionVisibility() } @@ -341,7 +342,7 @@ class DuckChatContextualEntryDialog : DuckDuckGoBottomSheetDialogFragment() { } .launchIn(viewLifecycleOwner.lifecycleScope) viewModel.viewState - .map { it.attachedContext?.serialized } + .map { it.latestPageContext } .filterNotNull() .distinctUntilChanged() .onEach { binding.entrySuggestionsView.onPageContextUpdated(it) } 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..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 @@ -17,17 +17,22 @@ 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.TextSelectionPayloadBuilder +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 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 +48,14 @@ class DuckChatContextualEntryViewModel @Inject constructor( private val contextualEntryPromptStore: ContextualEntryPromptStore, private val duckChatPixels: DuckChatPixels, private val modelManager: DuckAiModelManager, + private val textSelectionRepository: TextSelectionRepository, + private val selectionPayloadBuilder: TextSelectionPayloadBuilder, ) : ViewModel() { data class ViewState( val attachedContext: AttachedPageContext? = null, + val latestPageContext: String? = null, + val textSelectionCount: Int = 0, ) data class AttachedPageContext( @@ -77,18 +86,23 @@ class DuckChatContextualEntryViewModel @Inject constructor( fun start(tabId: String) { this.tabId = tabId duckChatPixels.reportContextualFloatingInputShown() + textSelectionRepository.selections(tabId) + .onEach { selections -> _viewState.update { it.copy(textSelectionCount = selections.size) } } + .launchIn(viewModelScope) } 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 +117,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 +148,21 @@ class DuckChatContextualEntryViewModel @Inject constructor( } private fun submit(prompt: NativeInputPrompt) { + val selectionsJson = prompt.selectionsJson ?: selectionPayloadBuilder.toJson(textSelectionRepository.consume(tabId)) contextualEntryPromptStore.store( - ContextualEntryPrompt(tabId, prompt, _viewState.value.attachedContext?.serialized), + ContextualEntryPrompt( + tabId = tabId, + prompt = prompt, + serializedPageContext = _viewState.value.attachedContext?.serialized, + selectionsJson = selectionsJson, + ), ) duckChatPixels.reportContextualFloatingInputPromotedToSheet() commandChannel.trySend(Command.HandOffToSheet) } + private fun hasTextSelections(): Boolean = textSelectionRepository.selections(tabId).value.isNotEmpty() + private fun attach(serializedPageContext: String) { val json = runCatching { JSONObject(serializedPageContext) }.getOrNull() ?: return userRemovedContext = false 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 f550746018e2..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 @@ -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, ) } @@ -355,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( @@ -369,9 +377,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 +469,7 @@ class DuckChatContextualWebViewViewModel @Inject constructor( imagesJson: JSONArray? = null, filesJson: JSONArray? = null, pageContextSerialized: String?, + selectionsJson: JSONArray? = null, ): SubscriptionEventData { val pageContext = pageContextSerialized @@ -502,6 +513,7 @@ class DuckChatContextualWebViewViewModel @Inject constructor( }, ) pageContext?.let { put("pageContext", it) } + selectionsJson?.let { put("selections", it) } } return SubscriptionEventData( 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..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,6 +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.TextSelectionRepository import com.duckduckgo.navigation.api.GlobalActivityStarter import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -51,34 +52,41 @@ class RealDuckChatContextual @Inject constructor( private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, private val contextualEntryPromptStore: ContextualEntryPromptStore, private val globalActivityStarter: GlobalActivityStarter, + private val textSelectionRepository: TextSelectionRepository, ) : DuckChatContextual { override suspend fun launch( sourceTabId: String, sourceUrl: String?, anchor: View?, + textSelection: String?, showChatSurface: () -> Unit, ) { if (anchor == null || !duckChatInternal.isContextualSheetRedesignEnabled()) { showChatSurface() return } + 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 (textSelectionRepository.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). 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 { @@ -136,6 +144,7 @@ class RealDuckChatContextual @Inject constructor( } else { popup.onMenuItemClicked(askItem) { duckChatPixels.reportContextualAddressBarMenuAskAboutPageSelected() + textSelectionRepository.consume(sourceTabId) showEntryDialog(anchor, sourceTabId, onAskAboutPage) } } 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..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 @@ -31,6 +31,7 @@ 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 } @@ -70,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, @@ -102,9 +111,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) { 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..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 @@ -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 { loadSuggestions(count) } + } + + private suspend fun loadSuggestions(textSelectionCount: Int) { + if (!suggestionsEnabled()) { + hideSuggestions() + return + } + if (textSelectionCount > 0) resolveTextSelectionSuggestions() else resolvePageSuggestions() + } + + private suspend fun resolvePageSuggestions() { + fetchSuggestions(lastInput?.url, lastInput?.pageTypeSignals) + showSuggestions() + } + + 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 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 } @@ -126,6 +163,15 @@ class ContextualSuggestionsViewModel @Inject constructor( hideSuggestions() return } + lastInput = ResolvePageSuggestionsInput( + pageTypeSignals = pageTypeSignals, + url = url, + uiLocale = Locale.getDefault().toLanguageTag(), + ) + if (textSelectionCount > 0) { + resolveTextSelectionSuggestions() + 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 } } 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..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 @@ -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 duckAiTextSelectionAction(): Toggle } 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..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,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(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 92f0f07a835d..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 @@ -41,10 +41,14 @@ 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 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.TextSelectionRepository +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -73,8 +77,13 @@ class AttachmentViewModel @Inject constructor( private val appBuildConfig: AppBuildConfig, nativeInputStateProvider: NativeInputStateProvider, private val duckChatPixels: DuckChatPixels, + private val textSelectionRepository: TextSelectionRepository, + 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"), @@ -84,6 +93,8 @@ class AttachmentViewModel @Inject constructor( val images: List = emptyList(), 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, @@ -93,7 +104,8 @@ 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 hasStandaloneAttachments: Boolean get() = images.isNotEmpty() || files.isNotEmpty() || pageContext != null val acceptedMimeTypes: List get() { val types = mutableListOf() if (supportedFileTypes.isNotEmpty()) types.addAll(supportedFileTypes) @@ -106,6 +118,8 @@ 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 _textSelectionLimitReached = MutableStateFlow(false) private val isDuckAiModeFlow: StateFlow = nativeInputStateProvider.state .map { it.inputContext != NativeInputState.InputContext.BROWSER } @@ -116,13 +130,19 @@ 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, + _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), 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 } @@ -139,6 +159,8 @@ class AttachmentViewModel @Inject constructor( images = images, files = files, pageContext = pageContext, + textSelections = selections, + textSelectionLimitError = computeTextSelectionLimitError(selectionLimitReached), imageLimitError = computeImageLimitError(currentImageCount, totalImages, imageLimits), fileLimitError = computeFileLimitError(totalFiles, fileLimits.maxPerConversation), fileSizeError = computeFileSizeError(files, fileLimits.maxFileSizeBytes), @@ -338,6 +360,35 @@ class AttachmentViewModel @Inject constructor( } } + private data class AttachmentLists( + val images: List, + val files: List, + val pageContext: PageContextAttachment?, + val textSelections: List, + val textSelectionLimitReached: Boolean, + ) + + fun bindTextSelections(tabId: String, textSelection: String?) { + textSelectionsTabId = tabId + textSelection?.let { textSelectionRepository.add(tabId, it, url = "") } + textSelectionsJob?.cancel() + textSelectionsJob = viewModelScope.launch { + 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 { textSelectionRepository.remove(it, id) } + } + + fun getTextSelectionsJson(): JSONArray? { + val tabId = textSelectionsTabId ?: return null + return textSelectionPayloadBuilder.toJson(textSelectionRepository.consume(tabId)) + } + fun setPageContext(attachment: PageContextAttachment) { _pageContextAttachment.value = attachment } @@ -353,6 +404,7 @@ class AttachmentViewModel @Inject constructor( imageAttachments.value = emptyList() _fileAttachments.value = emptyList() _pageContextAttachment.value = null + _textSelections.value = emptyList() viewModelScope.launch { toRecycle.forEach { it.bitmap.recycle() } } } @@ -397,6 +449,13 @@ class AttachmentViewModel @Inject constructor( } } + private fun computeTextSelectionLimitError(limitReached: Boolean): String? = + if (limitReached) { + context.getString(R.string.duckAiTextSelectionLimitReached, TextSelectionRepository.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/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/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..f37c93989c24 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/RealDuckAiTextSelectionDecorator.kt @@ -0,0 +1,82 @@ +/* + * 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.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( + private val duckChatInternal: DuckChatInternal, +) : DuckAiTextSelectionDecorator { + + 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?.decorateWithDuckAi(isHidden = hideAskDuckAi) + return isPrepared + } + + 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) + + 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.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) + } + + 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/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..25cab1231ab5 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/SelectedTextDuckAiActivity.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.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), + forceLaunchContextual = !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 { + val sender = callingPackage ?: referrer?.host + return sender != null && sender != packageName + } +} 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..6c1e00243f4a --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionPayloadBuilder.kt @@ -0,0 +1,75 @@ +/* + * 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.api.DuckAiHostProvider +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): JSONArray? + + companion object { + const val MAX_CONTENT_LENGTH = 9500 + } +} + +@SingleInstanceIn(AppScope::class) +@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 + return JSONArray().apply { + selections.forEach { selection -> put(toJson(selection, title)) } + } + } + + private fun toJson( + selection: TextSelection, + title: 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", 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) + put("wordCount", selection.text.split(WHITESPACE).count { it.isNotEmpty() }) + } + } + + private companion object { + val WHITESPACE = Regex("\\s+") + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepository.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepository.kt new file mode 100644 index 000000000000..d521f324e225 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepository.kt @@ -0,0 +1,92 @@ +/* + * 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 kotlinx.coroutines.flow.updateAndGet +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +data class TextSelection( + val id: String, + val text: String, + val url: String, +) + +interface TextSelectionRepository { + 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) + + companion object { + const val MAX_SELECTIONS = 5 + } +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealTextSelectionRepository @Inject constructor() : TextSelectionRepository { + + 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 >= TextSelectionRepository.MAX_SELECTIONS) existing else existing + selection + } + val isAttached = selections.any { it.text == selection.text } + if (!isAttached) getLimitFlow(tabId).value = true + return isAttached + } + + 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 + return TextSelection(UUID.randomUUID().toString(), trimmed, url) + } +} 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, + ) + } +} 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..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 @@ -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 @@ -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 { @@ -129,6 +132,13 @@ class AttachmentView( fun getFileAttachmentsJson(): JSONArray? = viewModel?.getFileAttachmentsJson() + 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( @@ -195,6 +205,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,8 +239,10 @@ class AttachmentView( syncImages(imagesView, state) syncFiles(state) syncPageContext(state) + syncTextSelections(state) val errorMessage = effectiveLimitError( - imageLimitError = state.imageLimitError + imageLimitError = state.textSelectionLimitError + ?: state.imageLimitError ?: state.fileLimitError ?: state.fileSizeError ?: state.filePageCountError @@ -258,6 +276,23 @@ 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) 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) + } + } + private fun syncPageContext(state: AttachmentViewModel.AttachmentState) { val view = pageContextContainer ?: return val next = state.pageContext @@ -285,7 +320,7 @@ class AttachmentView( supportsUpload = state.supportsUpload updateButtonVisibility() host?.attachmentChanged( - hasAttachments = state.hasAttachments, + 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 b6a1d7a5d3fc..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,6 +193,9 @@ interface NativeInputWidget { fun setWidgetPosition(isBottom: Boolean) fun setWidgetRootView(view: View) + 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. @@ -329,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 @@ -392,6 +395,8 @@ class NativeInputModeWidget @JvmOverloads constructor( private var pendingAskAboutPage: (() -> Unit)? = null private var pendingOnPageContextRemoved: (() -> Unit)? = null private var pendingPageContext: PageContextAttachment? = null + private var pendingTextSelectionsTabId: String? = null + private var pendingTextSelection: String? = 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 @@ -790,6 +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) } if (hasPendingAdoptedAttachments(pendingAdoptedImages, pendingAdoptedFiles)) { pluginView.adoptAttachments(pendingAdoptedImages, pendingAdoptedFiles) } @@ -1090,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) @@ -1098,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) { @@ -1364,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() @@ -1647,6 +1653,17 @@ class NativeInputModeWidget @JvmOverloads constructor( override fun getPageContext(): PageContextAttachment? = attachmentView?.getPageContext() + override fun bindTextSelections(tabId: String, textSelection: String?) { + pendingTextSelectionsTabId = tabId + pendingTextSelection = textSelection + attachmentView?.let { view -> + view.bindTextSelections(tabId, textSelection) + pendingTextSelection = null + } + } + + override fun getTextSelectionsJson(): JSONArray? = attachmentView?.getTextSelectionsJson() + override fun setContextualAttachmentActions( onAskAboutPage: () -> Unit, onPageContextRemoved: () -> Unit, @@ -2088,13 +2105,13 @@ class NativeInputModeWidget @JvmOverloads constructor( } override fun attachmentChanged( - hasAttachments: Boolean, + hasStandaloneAttachments: Boolean, limitExceeded: Boolean, supportsUpload: Boolean, ) { val hadLimitError = attachmentLimitExceeded attachmentLimitExceeded = limitExceeded - this.hasAttachments = hasAttachments + this.hasStandaloneAttachments = hasStandaloneAttachments if (hadLimitError != attachmentLimitExceeded && !isStreaming) { floatingSubmitContainer?.visibility = if (attachmentLimitExceeded) GONE else VISIBLE } 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..a92136939c66 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/TextSelectionAttachmentsContainerView.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.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 + + 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_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) + } + } + + 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/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 @@ + + + + + + + + + + + + diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index 2246c8474f1f..8d566cd0f301 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -43,4 +43,23 @@ Select Chats Chat Protection + + + Summarize this selection + Summarize this selection. + Translate this selection + Translate this selection into %1$s. + + + Text selection + You can add up to %1$d text selections. Remove one to add another. + + + Ask Duck.ai + + + + %1$d word + %1$d words + 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 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..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 @@ -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,60 @@ 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)) + duckChatFeature.nativeInputField().setRawStoredState(State(enable = true)) + duckChatFeature.nativeChatInput().setRawStoredState(State(enable = true)) + duckChatFeature.contextualNativeInput().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 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)) + 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)) @@ -1991,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) } @@ -2007,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) } @@ -2023,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) } 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..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 @@ -16,15 +16,26 @@ 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 import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.RealTextSelectionPayloadBuilder +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.TextSelectionRepository 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 +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 +43,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 textSelectionRepository = RealTextSelectionRepository() + private val viewModel = DuckChatContextualEntryViewModel( + store, + duckChatPixels, + modelManager, + textSelectionRepository, + RealTextSelectionPayloadBuilder(RuntimeEnvironment.getApplication(), object : DuckAiHostProvider {}), + ) 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 +255,6 @@ class DuckChatContextualEntryViewModelTest { @Test fun whenPromptSubmittedThenReportsFloatingInputPromotedToSheet() = runTest { viewModel.start("tab-1") - viewModel.commands.test { viewModel.onPromptSubmitted(samplePrompt) assertEquals(DuckChatContextualEntryViewModel.Command.HandOffToSheet, awaitItem()) @@ -278,4 +297,143 @@ class DuckChatContextualEntryViewModelTest { verify(duckChatPixels).reportContextualPageContextRemovedNative() } + + @Test + fun whenTextSelectionAttachedThenPageContextNotAttached() = runTest { + viewModel.start("tab-1") + textSelectionRepository.add("tab-1", "selected words", "https://example.com") + + viewModel.onPageContextReceived(validContext) + + assertNull(viewModel.viewState.value.attachedContext) + } + + @Test + fun whenPromptSubmittedWithTextSelectionsThenSelectionsSentOnOwnKeyAndCleared() = runTest { + viewModel.start("tab-1") + viewModel.onPageContextReceived(validContext) + textSelectionRepository.add("tab-1", "first selection", "https://example.com") + textSelectionRepository.add("tab-1", "second selection", "https://example.com") + + 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(textSelectionRepository.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 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") + textSelectionRepository.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") + textSelectionRepository.add("tab-1", "from imdb", "https://imdb.com") + textSelectionRepository.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") + textSelectionRepository.add("tab-1", long, "https://example.com") + + 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(TextSelectionPayloadBuilder.MAX_CONTENT_LENGTH, selection.getString("content").length) + assertEquals(long.trim().length, selection.getInt("fullContentLength")) + assertEquals(3000, selection.getInt("wordCount")) + } + + @Test + fun whenSelectionsExceedMaxThenExtrasDropped() { + repeat( + TextSelectionRepository.MAX_SELECTIONS + 2, + ) { index -> textSelectionRepository.add("tab-1", "selection $index", "https://example.com") } + + assertEquals(TextSelectionRepository.MAX_SELECTIONS, textSelectionRepository.consume("tab-1").size) + } + + @Test + fun whenSelectionRemovedThenDroppedFromStore() { + 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() + + textSelectionRepository.remove("tab-1", target.id) + + 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 c591a60608a2..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,6 +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.RealTextSelectionRepository import com.duckduckgo.navigation.api.GlobalActivityStarter import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -41,6 +42,7 @@ class RealDuckChatContextualTest { private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private val contextualEntryPromptStore = RealContextualEntryPromptStore() private val globalActivityStarter: GlobalActivityStarter = mock() + private val textSelectionRepository = RealTextSelectionRepository() private val anchor: View = mock() private val testee = RealDuckChatContextual( @@ -53,6 +55,7 @@ class RealDuckChatContextualTest { duckDuckGoUrlDetector, contextualEntryPromptStore, globalActivityStarter, + textSelectionRepository, ) @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..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 @@ -442,4 +442,30 @@ 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 whenMoreThanOneTextSelectionAttachedThenSelectionSuggestionsHidden() = 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()) + } } 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..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,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.RealTextSelectionRepository +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionPayloadBuilder +import com.duckduckgo.duckchat.impl.ui.nativeinput.textselection.TextSelectionRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -87,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, TextSelectionRepository.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)) @@ -124,6 +129,9 @@ class AttachmentViewModelTest { private lateinit var viewModel: AttachmentViewModel + private val textSelectionRepository = RealTextSelectionRepository() + private val textSelectionPayloadBuilder: TextSelectionPayloadBuilder = mock() + @Before fun setUp() { viewModel = AttachmentViewModel( @@ -136,6 +144,8 @@ class AttachmentViewModelTest { appBuildConfig = appBuildConfig, nativeInputStateProvider = nativeInputStateStore, duckChatPixels = duckChatPixels, + textSelectionRepository = textSelectionRepository, + textSelectionPayloadBuilder = textSelectionPayloadBuilder, ) } @@ -277,6 +287,106 @@ class AttachmentViewModelTest { assertTrue(viewModel.attachmentState.value.hasAttachments) } + @Test + fun whenAtSelectionLimitThenNoLimitErrorUntilAnotherIsAttempted() = runTest { + viewModel.bindTextSelections("tab-1", textSelection = null) + repeat(TextSelectionRepository.MAX_SELECTIONS) { textSelectionRepository.add("tab-1", "selection $it", "https://example.com") } + + assertNull(viewModel.attachmentState.value.textSelectionLimitError) + } + + @Test + fun whenSelectionRefusedAtLimitThenLimitErrorShown() = runTest { + viewModel.bindTextSelections("tab-1", textSelection = null) + repeat(TextSelectionRepository.MAX_SELECTIONS) { textSelectionRepository.add("tab-1", "selection $it", "https://example.com") } + + textSelectionRepository.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(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) + + assertNull(viewModel.attachmentState.value.textSelectionLimitError) + } + + @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 { + textSelectionRepository.add("tab-other", "not mine", "https://example.com") + + 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) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepositoryTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepositoryTest.kt new file mode 100644 index 000000000000..ffcc9255729c --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/nativeinput/textselection/TextSelectionRepositoryTest.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 TextSelectionRepositoryTest { + + private val testee = RealTextSelectionRepository() + + @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(TextSelectionRepository.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(TextSelectionRepository.MAX_SELECTIONS) { testee.add(TAB, "selection $it", URL) } + } + + private companion object { + const val TAB = "tab-1" + const val URL = "https://example.com" + } +}