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 6d3e3355f3c6..39e6e6fae23b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -133,6 +133,7 @@ import com.duckduckgo.downloads.api.DownloadsScreens.DownloadsScreenNoParams import com.duckduckgo.duckchat.api.DuckAiFeatureState import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckchat.api.DuckChatEntryPoint +import com.duckduckgo.duckchat.api.InputMode import com.duckduckgo.duckchat.api.viewmodel.DuckChatSharedViewModel import com.duckduckgo.feedback.api.FeedbackScreenNoParams import com.duckduckgo.navigation.api.GlobalActivityStarter @@ -295,6 +296,8 @@ open class BrowserActivity : DuckDuckGoActivity() { // we don't store isExternal in the tab model, as it's only meant for the first time the tab is loaded. private val externalLaunchTabIds = mutableSetOf() + private val pendingInputModeTargets = mutableMapOf() + private lateinit var renderer: BrowserStateRenderer private val binding: ActivityBrowserBinding by viewBinding() @@ -351,6 +354,11 @@ open class BrowserActivity : DuckDuckGoActivity() { var isDataClearingInProgress: Boolean = false var isDuckChatVisible: Boolean = false + // One-shot carried from an "open Duck.ai for image generation" launch. The Duck.ai tab this + // launch creates doesn't exist yet, so the flag is held here and consumed by that tab's fragment + // the first time it shows the native input (see BrowserTabFragment.consumeDuckAiForceImageGeneration). + private var pendingDuckChatForceImageGeneration: Boolean = false + private val startBookmarksActivityForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> if (result.resultCode == RESULT_OK) { @@ -687,6 +695,7 @@ open class BrowserActivity : DuckDuckGoActivity() { ): BrowserTabFragment { logcat(INFO) { "Opening new tab, url: $url, tabId: $tabId" } val fragment = BrowserTabFragment.newInstance(tabId, url, skipHome, isExternal) + fragment.inputModeTarget = consumeInputModeTargetForTab(tabId) addOrReplaceNewTab(fragment, tabId) currentTab = fragment return fragment @@ -714,6 +723,10 @@ open class BrowserActivity : DuckDuckGoActivity() { return externalLaunchTabIds.remove(tabId) } + fun consumeInputModeTargetForTab(tabId: String): InputMode? { + return pendingInputModeTargets.remove(tabId) + } + private fun selectTab(tab: TabEntity?) { logcat(VERBOSE) { "Select tab: $tab" } @@ -834,6 +847,7 @@ open class BrowserActivity : DuckDuckGoActivity() { if (intent.getBooleanExtra(OPEN_DUCK_CHAT, false)) { 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 -> runCatching { DuckChatEntryPoint.valueOf(source) } .getOrNull() @@ -1111,6 +1125,13 @@ open class BrowserActivity : DuckDuckGoActivity() { uri.getQueryParameter("prompt") == "1" && !uri.getQueryParameter("q").isNullOrBlank() }.getOrDefault(false) + /** + * Returns whether the Duck.ai tab being opened should preselect image generation, clearing the + * one-shot so later native-input shows behave normally. Consumed by the Duck.ai tab's fragment. + */ + fun consumeDuckChatForceImageGeneration(): Boolean = + pendingDuckChatForceImageGeneration.also { pendingDuckChatForceImageGeneration = false } + fun closeDuckChatFullScreen() { isDuckChatVisible = false currentTab?.closeCurrentTab() @@ -1297,6 +1318,7 @@ open class BrowserActivity : DuckDuckGoActivity() { closeDuckChat: Boolean = false, duckChatUrl: String? = null, duckChatSessionActive: Boolean = false, + duckChatForceImageGeneration: Boolean = false, deletedTabCount: Int = 0, ): Intent { val intent = Intent(context, BrowserActivity::class.java) @@ -1314,6 +1336,7 @@ open class BrowserActivity : DuckDuckGoActivity() { intent.putExtra(CLOSE_DUCK_CHAT, closeDuckChat) intent.putExtra(DUCK_CHAT_URL, duckChatUrl) intent.putExtra(DUCK_CHAT_SESSION_ACTIVE, duckChatSessionActive) + intent.putExtra(DUCK_CHAT_FORCE_IMAGE_GENERATION, duckChatForceImageGeneration) intent.putExtra(DELETED_TAB_COUNT_EXTRA, deletedTabCount) intent.putExtra(LAUNCH_REQUIRES_REGULAR_MODE, launchSource.requiresRegularMode) intent.putExtra(LAUNCH_SOURCE_PIXEL_VALUE, launchSource.toPixelLaunchSourceValue()) @@ -1350,6 +1373,7 @@ open class BrowserActivity : DuckDuckGoActivity() { private const val CLOSE_DUCK_CHAT = "CLOSE_DUCK_CHAT_EXTRA" private const val DUCK_CHAT_URL = "DUCK_CHAT_URL" private const val DUCK_CHAT_SESSION_ACTIVE = "DUCK_CHAT_SESSION_ACTIVE" + private const val DUCK_CHAT_FORCE_IMAGE_GENERATION = "DUCK_CHAT_FORCE_IMAGE_GENERATION" private const val MAX_ACTIVE_TABS = 40 private const val KEY_TAB_PAGER_STATE = "tabPagerState" @@ -1625,8 +1649,13 @@ open class BrowserActivity : DuckDuckGoActivity() { skipHome: Boolean = false, isExternal: Boolean = false, browserMode: BrowserMode = currentBrowserMode, + inputModeTarget: InputMode? = null, ) { - switchModeThen(browserMode, PendingAction.OpenNewTab(query, sourceTabId, skipHome, isExternal), BrowserModeSwitchSource.NEW_TAB) + switchModeThen( + browserMode, + PendingAction.OpenNewTab(query, sourceTabId, skipHome, isExternal, inputModeTarget), + BrowserModeSwitchSource.NEW_TAB, + ) } /** @@ -1668,6 +1697,7 @@ open class BrowserActivity : DuckDuckGoActivity() { action.sourceTabId, action.skipHome, action.isExternal, + action.inputModeTarget, ) is PendingAction.OpenExistingTab -> openExistingTab(action.tabId) } @@ -1678,6 +1708,7 @@ open class BrowserActivity : DuckDuckGoActivity() { sourceTabId: String?, skipHome: Boolean, isExternal: Boolean, + inputModeTarget: InputMode? = null, ) { lifecycleScope.launch { if (swipingTabsFeature.isEnabled) { @@ -1685,8 +1716,16 @@ open class BrowserActivity : DuckDuckGoActivity() { if (isExternal) { externalLaunchTabIds.add(tabId) } + // Stash before the tab-list observer fires and TabPagerAdapter builds the fragment, so + // the target is available at createFragment (mirrors externalLaunchTabIds above). + if (inputModeTarget != null) { + pendingInputModeTargets[tabId] = inputModeTarget + } } else { - viewModel.onNewTabRequested() + val tabId = viewModel.onNewTabRequested() + if (inputModeTarget != null) { + pendingInputModeTargets[tabId] = inputModeTarget + } } } } 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 15e0dc637a4e..c4bfcadfaa36 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -227,7 +227,6 @@ import com.duckduckgo.app.global.model.orderedTrackerBlockedEntities import com.duckduckgo.app.global.view.NonDismissibleBehavior import com.duckduckgo.app.global.view.launchDefaultAppActivity import com.duckduckgo.app.global.view.renderIfChanged -import com.duckduckgo.app.onboarding.OnboardingInputScreenLaunchTarget import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.BrowserModeSwitchSource import com.duckduckgo.app.settings.db.SettingsDataStore @@ -297,6 +296,7 @@ import com.duckduckgo.common.ui.store.AppBrandDesignUpdateToggles import com.duckduckgo.common.ui.store.BrowserAppTheme import com.duckduckgo.common.ui.tabs.SwipingTabsFeatureProvider import com.duckduckgo.common.ui.view.DaxDialog +import com.duckduckgo.common.ui.view.PopupMenuItemView import com.duckduckgo.common.ui.view.addClickableLink import com.duckduckgo.common.ui.view.dialog.ActionBottomSheetDialog import com.duckduckgo.common.ui.view.dialog.CustomAlertDialogBuilder @@ -341,6 +341,7 @@ import com.duckduckgo.downloads.api.DownloadConfirmationDialogListener import com.duckduckgo.downloads.api.DownloadsFileActions import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload +import com.duckduckgo.duckchat.api.DuckAiFeatureState import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckchat.api.DuckChatContextual import com.duckduckgo.duckchat.api.DuckChatEntryPoint @@ -662,6 +663,9 @@ class BrowserTabFragment : @Inject lateinit var duckChatContextual: DuckChatContextual + @Inject + lateinit var duckAiFeatureState: DuckAiFeatureState + @Inject lateinit var newAddressBarPickerManager: NewAddressBarPickerManager @@ -707,9 +711,6 @@ class BrowserTabFragment : @Inject lateinit var edgeToEdgeHandler: EdgeToEdgeHandler - @Inject - lateinit var onboardingInputScreenLaunchTarget: OnboardingInputScreenLaunchTarget - /** * We use this to monitor whether the user was seeing the in-context Email Protection signup prompt * This is needed because the activity stack will be cleared if an external link is opened in our browser @@ -721,6 +722,11 @@ class BrowserTabFragment : var messageFromPreviousTab: Message? = null + // One-shot input-screen mode this tab should land on (e.g. "New Search" → Search), set by whoever + // opened the tab and handed to the viewmodel in loadData. Not persisted: only meaningful for the + // tab's initial launch within this process. + var inputModeTarget: InputMode? = null + private val initialUrl get() = requireArguments().getString(URL_EXTRA_ARG) private val skipHome get() = requireArguments().getBoolean(SKIP_HOME_ARG) @@ -771,7 +777,7 @@ class BrowserTabFragment : private val viewModel: BrowserTabViewModel by lazy { val viewModel = ViewModelProvider(this, viewModelFactory)[BrowserTabViewModel::class.java] - viewModel.loadData(tabId, initialUrl, skipHome, isLaunchedFromExternalApp) + viewModel.loadData(tabId, initialUrl, skipHome, isLaunchedFromExternalApp, inputModeTarget) viewModel } @@ -835,9 +841,18 @@ class BrowserTabFragment : onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewVoiceChat)) { duckChat.openVoiceDuckChat(DuckChatEntryPoint.VOICE) } + onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewImage)) { + viewModel.openNewImageDuckChat(omnibar.viewMode) + } onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewTab)) { + // With the native sidebar this entry is relabelled "New Search": open the new tab with + // its input screen surfaced on the Search tab. The target is threaded to the new tab + // itself rather than armed globally, so it can't be consumed by another tab. viewModel.recordPendingNewTabOpenedExit() - browserActivity?.launchNewTab(browserMode = BrowserMode.REGULAR) + browserActivity?.launchNewTab( + browserMode = BrowserMode.REGULAR, + inputModeTarget = if (duckAiFeatureState.nativeDuckAiSidebar.value) InputMode.SEARCH else null, + ) } onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewFireTab)) { viewModel.recordPendingFireTabOpenedExit() @@ -1394,7 +1409,7 @@ class BrowserTabFragment : } } - private fun showNativeInput(query: String = "") { + private fun showNativeInput(query: String = "", forceImageGeneration: Boolean = false) { nativeInputManager.showNativeInput( tabId = tabId, layoutInflater = layoutInflater, @@ -1402,11 +1417,8 @@ class BrowserTabFragment : tabs = viewModel.tabs, currentTabUrl = viewModel.siteLiveData.asFlow().map { it?.url }, query = query, - initialInputMode = if (onboardingInputScreenLaunchTarget.consumeOpenOnDuckAi()) { - InputMode.DUCK_AI - } else { - null - }, + initialInputMode = viewModel.consumeInitialInputMode(), + forceImageGeneration = forceImageGeneration, callbacks = NativeInputCallbacks( onSearchTextChanged = { text -> onUserEnteredText(text) }, onClearAutocomplete = { @@ -2429,7 +2441,9 @@ class BrowserTabFragment : renderBrowserMenu(viewState = browserViewState, omnibarViewMode = ViewMode.DuckAI) omnibar.setViewMode(ViewMode.DuckAI) browserNavigationBarIntegration.configureDuckAIViewMode() - showNativeInput() + val forceImageGeneration = !nativeInputManager.isNativeInputShown() && + (browserActivity?.consumeDuckChatForceImageGeneration() ?: false) + showNativeInput(forceImageGeneration = forceImageGeneration) } private fun showMaliciousWarning( @@ -3952,6 +3966,21 @@ class BrowserTabFragment : chatMenuPopup.contentView .findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewFireTab) .isVisible = fireModeAvailability.isAvailable() + val nativeSidebarEnabled = duckAiFeatureState.nativeDuckAiSidebar.value + chatMenuPopup.contentView + .findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewImage) + .isVisible = nativeSidebarEnabled + chatMenuPopup.contentView + .findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewTab) + .setPrimaryText( + getString( + if (nativeSidebarEnabled) { + com.duckduckgo.browser.ui.R.string.chatMenuPopupNewSearch + } else { + com.duckduckgo.browser.ui.R.string.chatMenuPopupNewTab + }, + ), + ) chatMenuPopup.showAnchoredView(activity, binding.rootView, anchor) } @@ -3988,7 +4017,7 @@ class BrowserTabFragment : override fun onDuckAISidebarButtonPressed() { pixel.fire(DuckChatPixelName.DUCK_CHAT_OMNIBAR_SIDEBAR_TAPPED) - viewModel.openDuckChatSidebar() + viewModel.onDuckChatSidebarButtonPressed() } override fun onDuckAIBackButtonPressed() { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 15f7d3a9fc85..25f13f3fcabf 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -390,6 +390,7 @@ import com.duckduckgo.duckchat.api.DuckAiSessionExitTrigger import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.DuckChatInputModeState +import com.duckduckgo.duckchat.api.InputMode import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.impl.contextual.PageContextJSHelper import com.duckduckgo.duckchat.impl.contextual.RealPageContextJSHelper.Companion.PAGE_CONTEXT_FEATURE_NAME @@ -781,6 +782,8 @@ class BrowserTabViewModel @Inject constructor( @VisibleForTesting internal var previousUrl: String? = null private lateinit var tabId: String + + private var inputModeTarget: InputMode? = null private var webNavigationState: WebNavigationState? = null private var httpsUpgraded = false private var adBlockingAnimationClaimed = false @@ -1012,9 +1015,11 @@ class BrowserTabViewModel @Inject constructor( initialUrl: String?, skipHome: Boolean, isExternal: Boolean, + inputModeTarget: InputMode? = null, ) { this.tabId = tabId this.skipHome = skipHome + this.inputModeTarget = inputModeTarget siteLiveData = tabRepository.retrieveSiteData(tabId) site = siteLiveData.value @@ -3912,6 +3917,15 @@ class BrowserTabViewModel @Inject constructor( } } + /** + * The input-screen mode the next auto-launched input screen on this tab should open in, cleared as + * it is read. Prefers this tab's own launch target (e.g. "New Search" → Search) and falls back to + * the post-onboarding signal (→ Duck.ai). Returns `null` when neither is armed. + */ + fun consumeInitialInputMode(): InputMode? = + inputModeTarget?.also { inputModeTarget = null } + ?: if (onboardingInputScreenLaunchTarget.consumeOpenOnDuckAi()) InputMode.DUCK_AI else null + fun onUserClickCtaOkButton(cta: Cta) { releaseAddWidgetModalSlot(cta) viewModelScope.launch { @@ -5808,6 +5822,15 @@ class BrowserTabViewModel @Inject constructor( } } + fun openNewImageDuckChat(viewMode: ViewMode) { + val entryPoint = if (viewMode == ViewMode.NewTab) { + DuckChatEntryPoint.BROWSING_MENU_NTP + } else { + DuckChatEntryPoint.BROWSING_MENU_WEBPAGE + } + duckChat.openDuckChatImageGeneration(entryPoint) + } + fun openNewDuckChat(viewMode: ViewMode) { if (viewMode == ViewMode.DuckAI) { pixel.fire(DuckChatPixelName.DUCK_CHAT_OMNIBAR_NEW_CHAT_TAPPED) @@ -5829,16 +5852,24 @@ class BrowserTabViewModel @Inject constructor( } } - fun openDuckChatSidebar() { + fun onCustomizeResponsesClicked() { viewModelScope.launch { - val subscriptionEvent = duckChatJSHelper.onNativeAction(NativeAction.SIDEBAR) + val subscriptionEvent = duckChatJSHelper.onNativeAction(NativeAction.CUSTOMIZE_RESPONSES) _subscriptionEventDataChannel.send(subscriptionEvent) } } - fun onCustomizeResponsesClicked() { + fun onDuckChatSidebarButtonPressed() { + if (duckAiFeatureState.nativeDuckAiSidebar.value) { + openDuckChatHistory() + } else { + openDuckChatSidebar() + } + } + + fun openDuckChatSidebar() { viewModelScope.launch { - val subscriptionEvent = duckChatJSHelper.onNativeAction(NativeAction.CUSTOMIZE_RESPONSES) + val subscriptionEvent = duckChatJSHelper.onNativeAction(NativeAction.SIDEBAR) _subscriptionEventDataChannel.send(subscriptionEvent) } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/PendingModeSwitch.kt b/app/src/main/java/com/duckduckgo/app/browser/PendingModeSwitch.kt index 46c39e90a902..66266a380f63 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/PendingModeSwitch.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/PendingModeSwitch.kt @@ -22,6 +22,7 @@ import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import com.duckduckgo.app.pixels.BrowserModeSwitchSource import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.duckchat.api.InputMode /** * An action to run once the browser is in a given [BrowserMode]. Must be bundle-encodable (see @@ -39,6 +40,7 @@ internal sealed class PendingAction { val sourceTabId: String?, val skipHome: Boolean, val isExternal: Boolean, + val inputModeTarget: InputMode? = null, ) : PendingAction() data class OpenExistingTab(val tabId: String) : PendingAction() } @@ -63,6 +65,7 @@ internal fun PendingModeSwitch.toBundle(): Bundle { bundle.putString(KEY_SOURCE_TAB_ID, pendingAction.sourceTabId) bundle.putBoolean(KEY_SKIP_HOME, pendingAction.skipHome) bundle.putBoolean(KEY_IS_EXTERNAL, pendingAction.isExternal) + bundle.putString(KEY_INPUT_MODE_TARGET, pendingAction.inputModeTarget?.name) } is PendingAction.OpenExistingTab -> { bundle.putString(KEY_ACTION, ACTION_OPEN_EXISTING_TAB) @@ -85,6 +88,7 @@ internal fun Bundle.toPendingModeSwitch(): PendingModeSwitch? { sourceTabId = getString(KEY_SOURCE_TAB_ID), skipHome = getBoolean(KEY_SKIP_HOME), isExternal = getBoolean(KEY_IS_EXTERNAL), + inputModeTarget = getString(KEY_INPUT_MODE_TARGET)?.let { runCatching { InputMode.valueOf(it) }.getOrNull() }, ) ACTION_OPEN_EXISTING_TAB -> PendingAction.OpenExistingTab( tabId = getString(KEY_EXISTING_TAB_ID) ?: return null, @@ -102,6 +106,7 @@ private const val KEY_QUERY = "pendingModeSwitchQuery" private const val KEY_SOURCE_TAB_ID = "pendingModeSwitchSourceTabId" private const val KEY_SKIP_HOME = "pendingModeSwitchSkipHome" private const val KEY_IS_EXTERNAL = "pendingModeSwitchIsExternal" +private const val KEY_INPUT_MODE_TARGET = "pendingModeSwitchInputModeTarget" private const val KEY_EXISTING_TAB_ID = "pendingModeSwitchExistingTabId" private const val ACTION_PROCESS_INTENT = "processIntent" private const val ACTION_OPEN_NEW_TAB = "openNewTab" 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 add59b648bea..c14568708b79 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 @@ -147,6 +147,7 @@ interface NativeInputManager { query: String = "", callbacks: NativeInputCallbacks, initialInputMode: InputMode? = null, + forceImageGeneration: Boolean = false, ) fun hideNativeInput(animate: Boolean = true, isNavigation: Boolean = false): Boolean @@ -586,6 +587,7 @@ class RealNativeInputManager @Inject constructor( query: String, callbacks: NativeInputCallbacks, initialInputMode: InputMode?, + forceImageGeneration: Boolean, ) { if (!isNativeInputFieldEnabled) return @@ -655,7 +657,7 @@ class RealNativeInputManager @Inject constructor( } } bindUrlCaching(widgetView) - attachWidget(widgetView, navBarView, isBottom, tabId) + attachWidget(widgetView, navBarView, isBottom, tabId, forceImageGeneration) // 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). @@ -1229,7 +1231,7 @@ class RealNativeInputManager @Inject constructor( ) } - private fun attachWidget(widgetView: View, navBarView: View?, isBottom: Boolean, tabId: String) { + private fun attachWidget(widgetView: View, navBarView: View?, isBottom: Boolean, tabId: String, forceImageGeneration: Boolean) { // 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 @@ -1257,7 +1259,12 @@ class RealNativeInputManager @Inject constructor( widgetFrom(widgetView)?.apply { setWidgetRootView(widgetView) - configure(tabId = tabId, isDuckAiMode = omnibarController.isDuckAiMode(), isBottom = isBottom) + configure( + tabId = tabId, + isDuckAiMode = omnibarController.isDuckAiMode(), + isBottom = isBottom, + forceImageGeneration = forceImageGeneration, + ) } applyWindowChrome(widgetView, isBottom) @@ -1270,6 +1277,14 @@ class RealNativeInputManager @Inject constructor( // Stash so showNativeInput can avoid clearing isWidgetAnimating when the open slide // finishes before the enter morph. pendingEnterOwnsAnimating = enterStarted + + if (forceImageGeneration) { + widgetView.doOnAttach { + if (widgetRoot === widgetView) { + widgetFrom(widgetView)?.focusInput(rootView.context as? Activity) + } + } + } } override fun setInteractionLock(lock: InteractionLock) { 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 d7e96f94a629..5f3a787e4524 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 @@ -54,6 +54,7 @@ class AppBrowserNav @Inject constructor() : BrowserNav { context: Context, hasSessionActive: Boolean, duckChatUrl: String, + forceImageGeneration: Boolean, ): Intent { return BrowserActivity.intent( context = context, @@ -61,6 +62,7 @@ class AppBrowserNav @Inject constructor() : BrowserNav { openDuckChat = true, duckChatUrl = duckChatUrl, duckChatSessionActive = hasSessionActive, + duckChatForceImageGeneration = forceImageGeneration, ) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/TabPagerAdapter.kt b/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/TabPagerAdapter.kt index f2f8ed44c992..59a20613aaad 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/TabPagerAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/TabPagerAdapter.kt @@ -73,13 +73,17 @@ class TabPagerAdapter( // Check if there's a message specifically for this tab's source tab ID val pendingMessage = pendingMessages.remove(tab.sourceTabId) pendingMessage?.cleanupJob?.cancel() + val inputModeTarget = activity.consumeInputModeTargetForTab(tab.tabId) return if (pendingMessage != null) { BrowserTabFragment.newInstance(tab.tabId, null, false, isExternal).apply { this.messageFromPreviousTab = pendingMessage.message + this.inputModeTarget = inputModeTarget } } else { - BrowserTabFragment.newInstance(tab.tabId, tab.url, tab.skipHome, isExternal) + BrowserTabFragment.newInstance(tab.tabId, tab.url, tab.skipHome, isExternal).apply { + this.inputModeTarget = inputModeTarget + } } } diff --git a/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 347498d347dc..610add0cb26a 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -322,6 +322,7 @@ import com.duckduckgo.duckchat.api.DuckAiSessionExitTrigger import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.DuckChatInputModeState +import com.duckduckgo.duckchat.api.InputMode import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.impl.contextual.PageContextJSHelper import com.duckduckgo.duckchat.impl.contextual.RealPageContextJSHelper.Companion.PAGE_CONTEXT_FEATURE_NAME @@ -870,6 +871,8 @@ class BrowserTabViewModelTest { whenever(mockDuckAiFeatureState.showPopupMenuShortcut).thenReturn(MutableStateFlow(false)) whenever(mockDuckAiFeatureState.showInputScreen).thenReturn(mockDuckAiFeatureStateInputScreenFlow) whenever(mockDuckAiFeatureState.showContextualMode).thenReturn(mockDuckAiContextualModeFlow) + whenever(mockDuckAiFeatureState.nativeDuckAiSidebar).thenReturn(MutableStateFlow(false)) + whenever(mockDuckAiFeatureState.nativeInputFieldEnabled).thenReturn(MutableStateFlow(false)) whenever(mockDuckChatInputModeState.inputModeCapability).thenReturn(mockInputModeCapability) whenever(mockVpnMenuStateProvider.getVpnMenuState()).thenReturn(flowOf(VpnMenuState.Hidden)) whenever(nonHttpAppLinkChecker.isPermitted(anyOrNull())).thenReturn(true) @@ -1234,6 +1237,40 @@ class BrowserTabViewModelTest { assertCommandNotIssued() } + @Test + fun whenViewBecomesVisibleAndInputModeTargetArmedThenTargetDoesNotForceKeyboardOverInputScreen() = + runTest { + whenever(mockWidgetCapabilities.hasInstalledWidgets).thenReturn(true) + whenever(mockDuckAiFeatureState.showInputScreen).thenReturn(MutableStateFlow(true)) + testee.loadData("abc", null, false, false, InputMode.SEARCH) + + testee.onViewVisible() + + // The launch target does not override the input-screen focus-drop rule. + assertCommandNotIssued() + } + + @Test + fun whenInputModeTargetArmedThenConsumeInitialInputModeReturnsItThenClears() = + runTest { + testee.loadData("abc", null, false, false, InputMode.SEARCH) + + assertEquals(InputMode.SEARCH, testee.consumeInitialInputMode()) + // One-shot: a second read no longer returns it. + assertNull(testee.consumeInitialInputMode()) + } + + @Test + fun whenViewBecomesVisibleAndInputModeTargetNotArmedThenConsumeReturnsNull() = + runTest { + whenever(mockWidgetCapabilities.hasInstalledWidgets).thenReturn(true) + testee.loadData("abc", null, false, false) + + testee.onViewVisible() + + assertNull(testee.consumeInitialInputMode()) + } + @Test fun whenViewBecomesVisibleAndBrowserShowingThenKeyboardHidden() { setBrowserShowing(true) @@ -11855,6 +11892,20 @@ class BrowserTabViewModelTest { verify(mockPixel).fire(DuckChatPixelName.DUCK_CHAT_OMNIBAR_NEW_CHAT_TAPPED) } + @Test + fun whenOpenNewImageDuckChatFromDuckAiThenOpensImageGenerationWithWebpageEntryPoint() = runTest { + testee.openNewImageDuckChat(ViewMode.DuckAI) + + verify(mockDuckChat).openDuckChatImageGeneration(DuckChatEntryPoint.BROWSING_MENU_WEBPAGE) + } + + @Test + fun whenOpenNewImageDuckChatFromNewTabThenOpensImageGenerationWithNtpEntryPoint() = runTest { + testee.openNewImageDuckChat(ViewMode.NewTab) + + verify(mockDuckChat).openDuckChatImageGeneration(DuckChatEntryPoint.BROWSING_MENU_NTP) + } + @Test fun whenDuckChatMenuItemClickedThenOpenNewDuckChatTab() = runTest { @@ -11873,15 +11924,15 @@ class BrowserTabViewModelTest { } @Test - fun whenDuckChatNativeHistoryRequested() = runTest { + fun whenCustomizeResponsesClickedThenSubscriptionEventEmitted() = runTest { val expectedEvent = SubscriptionEventData( - featureName = "event1", - subscriptionName = "subscription1", + featureName = "aiChat", + subscriptionName = "submitCustomizeResponsesAction", params = JSONObject(), ) - whenever(mockDuckChatJSHelper.onNativeAction(NativeAction.SIDEBAR)).thenReturn(expectedEvent) + whenever(mockDuckChatJSHelper.onNativeAction(NativeAction.CUSTOMIZE_RESPONSES)).thenReturn(expectedEvent) - testee.openDuckChatSidebar() + testee.onCustomizeResponsesClicked() testee.subscriptionEventDataFlow.test { val emittedEvent = awaitItem() @@ -11893,46 +11944,58 @@ class BrowserTabViewModelTest { } @Test - fun whenCustomizeResponsesClickedThenSubscriptionEventEmitted() = runTest { + fun whenOpenDuckChatHistoryAndAvailableThenLaunchDuckChatHistoryCommandEmitted() = runTest { + testee.browserViewState.value = browserViewState().copy(showDuckChatHistoryOption = true) + + testee.openDuckChatHistory() + + assertCommandIssued() + verify(mockDuckChatJSHelper, never()).onNativeAction(NativeAction.SIDEBAR) + } + + @Test + fun whenOpenDuckChatHistoryAndUnavailableThenSidebarFallbackEmitted() = runTest { val expectedEvent = SubscriptionEventData( - featureName = "aiChat", - subscriptionName = "submitCustomizeResponsesAction", + featureName = "event1", + subscriptionName = "subscription1", params = JSONObject(), ) - whenever(mockDuckChatJSHelper.onNativeAction(NativeAction.CUSTOMIZE_RESPONSES)).thenReturn(expectedEvent) + testee.browserViewState.value = browserViewState().copy(showDuckChatHistoryOption = false) + whenever(mockDuckChatJSHelper.onNativeAction(NativeAction.SIDEBAR)).thenReturn(expectedEvent) - testee.onCustomizeResponsesClicked() + testee.openDuckChatHistory() + assertCommandNotIssued() testee.subscriptionEventDataFlow.test { val emittedEvent = awaitItem() assertEquals(expectedEvent.featureName, emittedEvent.featureName) assertEquals(expectedEvent.subscriptionName, emittedEvent.subscriptionName) - assertEquals(expectedEvent.params.toString(), emittedEvent.params.toString()) cancelAndIgnoreRemainingEvents() } } @Test - fun whenOpenDuckChatHistoryAndAvailableThenLaunchDuckChatHistoryCommandEmitted() = runTest { + fun whenDuckChatSidebarButtonPressedAndNativeSidebarEnabledThenHistoryLaunched() = runTest { + whenever(mockDuckAiFeatureState.nativeDuckAiSidebar).thenReturn(MutableStateFlow(true)) testee.browserViewState.value = browserViewState().copy(showDuckChatHistoryOption = true) - testee.openDuckChatHistory() + testee.onDuckChatSidebarButtonPressed() assertCommandIssued() verify(mockDuckChatJSHelper, never()).onNativeAction(NativeAction.SIDEBAR) } @Test - fun whenOpenDuckChatHistoryAndUnavailableThenSidebarFallbackEmitted() = runTest { + fun whenDuckChatSidebarButtonPressedAndNativeSidebarDisabledThenLegacySidebarEventEmitted() = runTest { val expectedEvent = SubscriptionEventData( featureName = "event1", subscriptionName = "subscription1", params = JSONObject(), ) - testee.browserViewState.value = browserViewState().copy(showDuckChatHistoryOption = false) + whenever(mockDuckAiFeatureState.nativeDuckAiSidebar).thenReturn(MutableStateFlow(false)) whenever(mockDuckChatJSHelper.onNativeAction(NativeAction.SIDEBAR)).thenReturn(expectedEvent) - testee.openDuckChatHistory() + testee.onDuckChatSidebarButtonPressed() assertCommandNotIssued() testee.subscriptionEventDataFlow.test { @@ -11943,6 +12006,26 @@ class BrowserTabViewModelTest { } } + @Test + fun whenOpenDuckChatSidebarThenSidebarSubscriptionEventEmitted() = runTest { + val expectedEvent = SubscriptionEventData( + featureName = "event1", + subscriptionName = "subscription1", + params = JSONObject(), + ) + whenever(mockDuckChatJSHelper.onNativeAction(NativeAction.SIDEBAR)).thenReturn(expectedEvent) + + testee.openDuckChatSidebar() + + testee.subscriptionEventDataFlow.test { + val emittedEvent = awaitItem() + assertEquals(expectedEvent.featureName, emittedEvent.featureName) + assertEquals(expectedEvent.subscriptionName, emittedEvent.subscriptionName) + assertEquals(expectedEvent.params.toString(), emittedEvent.params.toString()) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenDuckChatNativeSettingsRequested() = runTest { val expectedEvent = SubscriptionEventData( diff --git a/app/src/test/java/com/duckduckgo/app/browser/PendingModeSwitchTest.kt b/app/src/test/java/com/duckduckgo/app/browser/PendingModeSwitchTest.kt index 9367691aa09e..d154749d637d 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/PendingModeSwitchTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/PendingModeSwitchTest.kt @@ -20,6 +20,7 @@ import android.content.Intent import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.pixels.BrowserModeSwitchSource import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.duckchat.api.InputMode import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -53,6 +54,7 @@ class PendingModeSwitchTest { sourceTabId = "tab-123", skipHome = true, isExternal = true, + inputModeTarget = InputMode.SEARCH, ), source = BrowserModeSwitchSource.NEW_TAB, ) 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 6c58125978e7..a88c916fa0e1 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 @@ -430,6 +430,7 @@ class RealNativeInputManagerTest { onClearAutocomplete = {}, onStopTapped = {}, ), + forceImageGeneration = false, ) return widget } @@ -443,6 +444,7 @@ class RealNativeInputManagerTest { currentTabUrl = emptyFlow(), query = "", callbacks = mock(), + forceImageGeneration = false, ) } 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 064a752c9d81..7baa90aaa894 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 @@ -32,7 +32,18 @@ interface BrowserNav { */ fun openInNewTab(context: Context, url: String, sourceTabId: String? = null): Intent fun openInCurrentTab(context: Context, url: String): Intent - fun openDuckChat(context: Context, hasSessionActive: Boolean = false, duckChatUrl: String): Intent + + /** + * 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. + */ + fun openDuckChat( + context: Context, + hasSessionActive: Boolean = false, + duckChatUrl: String, + forceImageGeneration: Boolean = false, + ): Intent fun closeDuckChat(context: Context): Intent /** diff --git a/browser/browser-ui/src/main/res/values/donottranslate.xml b/browser/browser-ui/src/main/res/values/donottranslate.xml index a14975a05bc0..8ce3151562f9 100644 --- a/browser/browser-ui/src/main/res/values/donottranslate.xml +++ b/browser/browser-ui/src/main/res/values/donottranslate.xml @@ -25,6 +25,8 @@ Chats New Voice Chat + New Image New Tab + New Search New Fire Tab \ No newline at end of file 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 9bb85232491e..82ec2ffb2221 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 @@ -80,4 +80,10 @@ interface DuckAiFeatureState { * [showInputScreen] (which is computed with `&& !isNativeInputFieldEnabled`). */ val nativeInputFieldEnabled: StateFlow + + /** + * Indicates whether the Duck.ai sidebar button should open the native chat history screen + * instead of the legacy web sidebar. + */ + val nativeDuckAiSidebar: StateFlow } diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt index 9e2b39df6c36..ff8e9f9a5bf7 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt @@ -203,4 +203,12 @@ interface DuckChat { * attributed back to the picker. */ suspend fun onAddressBarPickerDuckAiSelected() + + /** + * Opens Duck.ai in a new chat tab from [entryPoint] with the image-generation tool preselected on + * the native input. When the currently selected model can't generate images, first switches the + * (global) model selection to an available image-capable model; falls back to a normal new chat when + * no accessible model supports image generation. + */ + fun openDuckChatImageGeneration(entryPoint: DuckChatEntryPoint) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index b1744a7042cb..4a46c09dd008 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -45,6 +45,8 @@ import com.duckduckgo.duckchat.api.InputMode import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.impl.feature.AIChatImageUploadFeature import com.duckduckgo.duckchat.impl.feature.DuckChatFeature +import com.duckduckgo.duckchat.impl.models.DuckAiModelManager +import com.duckduckgo.duckchat.impl.models.Tool import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.pixel.toPixelValue import com.duckduckgo.duckchat.impl.repository.AddressBarPickerAttributionRepository @@ -188,6 +190,9 @@ interface DuckChatInternal : DuckChat { /** Single source of truth for the Duck.ai chat URL shape. */ fun buildChatUrl(chatId: String): String + /** Returns the Duck.ai URL that opens with the chat protections panel open (e.g. https://duck.ai/chat?chatProtection=open). */ + fun getChatProtectionUrl(): String + /** * Calls onClose when a close event is emitted. */ @@ -479,6 +484,7 @@ class RealDuckChat @Inject constructor( private val voiceSessionStateManager: VoiceSessionStateManager, private val chatSuggestionsStore: ChatSuggestionsStore, private val duckAiTabSessionRepository: DuckAiTabSessionRepository, + private val duckAiModelManager: DuckAiModelManager, ) : DuckChatInternal, DuckAiFeatureState, DuckChatInputModeState, @@ -496,6 +502,7 @@ class RealDuckChat @Inject constructor( private val _showModelPickerEvents = MutableSharedFlow(extraBufferCapacity = 1) private val _editPromptRequests = MutableSharedFlow(extraBufferCapacity = 1) private val _nativeInputFieldEnabled = MutableStateFlow(false) + private val _nativeDuckAiSidebar = MutableStateFlow(false) private val _nativeChatInputEnabled = MutableStateFlow(false) private val _nativeInputNavBarEnabled = MutableStateFlow(false) private val _showVoiceSearchToggle = MutableStateFlow(false) @@ -723,6 +730,8 @@ class RealDuckChat @Inject constructor( override val nativeInputFieldEnabled: StateFlow = _nativeInputFieldEnabled.asStateFlow() + override val nativeDuckAiSidebar: StateFlow = _nativeDuckAiSidebar.asStateFlow() + override val chatState: StateFlow = _chatState.asStateFlow() override fun isImageUploadEnabled(): Boolean = isImageUploadEnabled @@ -748,6 +757,30 @@ class RealDuckChat @Inject constructor( addressBarPickerAttributionRepository.onPickerDuckAiSelected() } + override fun openDuckChatImageGeneration(entryPoint: DuckChatEntryPoint) { + logcat { "Duck.ai: openDuckChatImageGeneration" } + appCoroutineScope.launch(dispatchers.io()) { + // Only force image generation once an image-capable model is selected, otherwise the tool + // would be unavailable. The new tab this opens consumes the flag when its input configures. + val forceImageGeneration = ensureImageCapableModelSelected() + withContext(dispatchers.main()) { + reportDuckChatEntry(entryPoint, opensNewTab = true, hasPrompt = false) + openDuckChat(emptyMap(), forceNewSession = true, forceImageGeneration = forceImageGeneration) + } + } + } + + private suspend fun ensureImageCapableModelSelected(): Boolean { + val state = duckAiModelManager.modelState.value + val selectedModel = state.models.firstOrNull { it.id == state.selectedModelId } + if (selectedModel?.supportsTool(Tool.IMAGE_GENERATION) == true) return true + val imageCapableModel = state.models.firstOrNull { + it.isAccessible && it.supportsTool(Tool.IMAGE_GENERATION) + } ?: return false + duckAiModelManager.selectModel(imageCapableModel) + return true + } + override fun openDuckChatWithPrefill(query: String, entryPoint: DuckChatEntryPoint) { logcat { "Duck.ai: openDuckChatWithPrefill query $query" } reportDuckChatEntry(entryPoint, opensNewTab = true, hasPrompt = false) @@ -766,6 +799,9 @@ class RealDuckChat @Inject constructor( override fun getDuckChatSettingsUrl(): String = resolveDuckAiUrl(DUCK_CHAT_SETTINGS_WEB_LINK) + override fun getChatProtectionUrl(): String = + appendParameters(mapOf(CHAT_PROTECTION_QUERY_NAME to CHAT_PROTECTION_QUERY_VALUE) + nativeChatInputParameters(), getDuckChatLink()) + private fun addChatParameters( query: String, autoPrompt: Boolean, @@ -826,6 +862,7 @@ class RealDuckChat @Inject constructor( private fun openDuckChat( parameters: Map, forceNewSession: Boolean = false, + forceImageGeneration: Boolean = false, ) { val url = appendParameters(parameters + nativeChatInputParameters(), getDuckChatLink()) appCoroutineScope.launch(dispatchers.io()) { @@ -837,7 +874,7 @@ class RealDuckChat @Inject constructor( withContext(dispatchers.main()) { logcat { "Duck.ai: restoring Duck.ai session $url hasSessionActive $hasSessionActive" } - openDuckChatSession(url, hasSessionActive) + openDuckChatSession(url, hasSessionActive, forceImageGeneration) } } } @@ -845,11 +882,12 @@ class RealDuckChat @Inject constructor( private fun openDuckChatSession( url: String, hasSessionActive: Boolean, + forceImageGeneration: Boolean = false, ) { // if a new query was submitted we force a new session // we want to lose the context of the previous one if the user wanted a new query from outside Duck.ai browserNav - .openDuckChat(context, duckChatUrl = url, hasSessionActive = hasSessionActive) + .openDuckChat(context, duckChatUrl = url, hasSessionActive = hasSessionActive, forceImageGeneration = forceImageGeneration) .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(this) @@ -1040,6 +1078,7 @@ class RealDuckChat @Inject constructor( isImageUploadEnabled = imageUploadFeature.self().isEnabled() isStandaloneMigrationEnabled = duckChatFeature.standaloneMigration().isEnabled() _nativeInputFieldEnabled.value = duckChatFeature.nativeInputField().isEnabled() + _nativeDuckAiSidebar.value = duckChatFeature.nativeDuckAiSidebar().isEnabled() keepSessionAliveInMinutes = settingsJson?.sessionTimeoutMinutes ?: DEFAULT_SESSION_ALIVE @@ -1139,6 +1178,8 @@ class RealDuckChat @Inject constructor( private const val PROMPT_QUERY_VALUE = "1" private const val PLACEMENT_QUERY_NAME = "placement" private const val PLACEMENT_QUERY_VALUE = "sidebar" + private const val CHAT_PROTECTION_QUERY_NAME = "chatProtection" + private const val CHAT_PROTECTION_QUERY_VALUE = "open" private const val BANG_QUERY_NAME = "bang" private const val BANG_QUERY_VALUE = "true" private const val MODE_QUERY_NAME = "mode" 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 4d3cbc5aec54..25fea61ef252 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 @@ -306,4 +306,12 @@ interface DuckChatFeature { */ @Toggle.DefaultValue(DefaultFeatureValue.FALSE) fun updatedPickers(): Toggle + + /** + * @return `true` when the Duck.ai sidebar button should open the native chat history screen + * instead of the legacy web sidebar. + * If the remote feature is not present defaults to `internal`. + */ + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun nativeDuckAiSidebar(): Toggle } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt index ef06b06cbeff..23fe37b0ccd9 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt @@ -92,6 +92,8 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { ViewModelProvider(this, viewModelFactory)[ChatHistoryViewModel::class.java] } + private var overflowMenuEnabled = false + private val adapter = ChatHistoryAdapter( onChatClicked = { item -> viewModel.onChatRowClicked(item.chatId) }, onChatMoreClicked = { item, anchor -> showRowPopup(item, anchor) }, @@ -133,6 +135,13 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { binding.toolbar.inflateMenu(R.menu.menu_chat_history_default) binding.toolbar.setOnMenuItemClickListener(::onMenuItemClicked) + viewLifecycleOwner.lifecycleScope.launch { + overflowMenuEnabled = withContext(dispatchers.io()) { duckChatFeature.nativeDuckAiSidebar().isEnabled() } + if (!viewModel.isSelectMode() && !binding.searchBar.isVisible) { + binding.toolbar.menu.findItem(R.id.chat_history_action_overflow)?.isVisible = overflowMenuEnabled + } + } + binding.chatHistoryList.layoutManager = LinearLayoutManager(requireContext()) binding.chatHistoryList.adapter = adapter @@ -182,6 +191,14 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { when (event) { is ChatHistoryViewModel.NavigationEvent.OpenChat -> startActivity(browserNav.openInNewTab(requireContext(), event.url, event.sourceTabId)) + is ChatHistoryViewModel.NavigationEvent.OpenChatProtection -> + startActivity( + if (event.inNewTab) { + browserNav.openInNewTab(requireContext(), event.url, event.sourceTabId) + } else { + browserNav.openInCurrentTab(requireContext(), event.url) + }, + ) is ChatHistoryViewModel.NavigationEvent.OpenRename -> openRenameScreen(event.chatId, event.currentTitle) is ChatHistoryViewModel.NavigationEvent.ShowDownloadComplete -> showDownloadCompleteSnackbar(event.fileName) is ChatHistoryViewModel.NavigationEvent.ShowBulkDownloadComplete -> showBulkDownloadCompleteSnackbar(event.count) @@ -287,6 +304,7 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { binding.toolbar.menu.findItem(R.id.chat_history_action_new)?.isVisible = true binding.toolbar.menu.findItem(R.id.chat_history_action_search)?.isVisible = true binding.toolbar.menu.findItem(R.id.chat_history_action_download_selected)?.isVisible = false + binding.toolbar.menu.findItem(R.id.chat_history_action_overflow)?.isVisible = overflowMenuEnabled } private fun applySelectModeToolbar(count: Int) { @@ -298,6 +316,7 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { binding.toolbar.menu.findItem(R.id.chat_history_action_new)?.isVisible = false binding.toolbar.menu.findItem(R.id.chat_history_action_search)?.isVisible = false binding.toolbar.menu.findItem(R.id.chat_history_action_download_selected)?.isVisible = true + binding.toolbar.menu.findItem(R.id.chat_history_action_overflow)?.isVisible = false } private fun buildEntries( @@ -360,6 +379,10 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { viewModel.onDownloadSelectedRequested() true } + R.id.chat_history_action_overflow -> { + binding.toolbar.findViewById(R.id.chat_history_action_overflow)?.let { showOverflowPopup(it) } + true + } else -> false } @@ -378,6 +401,20 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) { // onBackPressedCallback.isEnabled is reset by render() — select mode may still be active. } + private fun showOverflowPopup(anchor: View) { + val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_overflow) + val view = popup.contentView + val selectChats = view.findViewById(R.id.selectChats) + if (viewModel.uiState.value is ChatHistoryUiState.Loaded) { + selectChats.show() + popup.onMenuItemClicked(selectChats) { viewModel.onEnterSelectMode() } + } else { + selectChats.gone() + } + popup.onMenuItemClicked(view.findViewById(R.id.chatsProtection)) { viewModel.onChatsProtectionClicked() } + popup.show(binding.root, anchor) + } + private fun showRowPopup(item: ChatHistoryItem, anchor: View) { viewLifecycleOwner.lifecycleScope.launch { val isRenameEnabled = withContext(dispatchers.io()) { duckChatFeature.renameChat().isEnabled() } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt index dfce85a73f31..1b9f91334ad5 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt @@ -16,6 +16,7 @@ package com.duckduckgo.duckchat.impl.history +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel @@ -287,6 +288,20 @@ class ChatHistoryViewModel @Inject constructor( controls.update { it.copy(mode = Mode.Selecting(emptySet())) } } + fun onChatsProtectionClicked() { + viewModelScope.launch { + val sourceTab = tabRepository.getSelectedTab() + val fromChatTab = sourceTab?.url?.let { duckChat.isDuckChatUrl(it.toUri()) } == true + navigationChannel.trySend( + NavigationEvent.OpenChatProtection( + url = duckChat.getChatProtectionUrl(), + sourceTabId = sourceTab?.tabId, + inNewTab = !fromChatTab, + ), + ) + } + } + fun onSelectionToggled(chatId: String) { controls.update { c -> val mode = c.mode as? Mode.Selecting ?: return@update c @@ -408,6 +423,7 @@ class ChatHistoryViewModel @Inject constructor( sealed interface NavigationEvent { data class OpenChat(val url: String, val sourceTabId: String?) : NavigationEvent + data class OpenChatProtection(val url: String, val sourceTabId: String?, val inNewTab: Boolean) : NavigationEvent data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent data class ShowDownloadComplete(val fileName: String) : NavigationEvent data class ShowBulkDownloadComplete(val count: Int) : NavigationEvent diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt index 306e5a1318a6..b37c5471841c 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt @@ -541,12 +541,17 @@ class NativeInputModeWidgetViewModel @Inject constructor( } } - fun configure(tabId: String, isDuckAiMode: Boolean, isBottom: Boolean) { + fun configure(tabId: String, isDuckAiMode: Boolean, isBottom: Boolean, forceImageGeneration: Boolean = false) { activeTabId.value = tabId val context = if (isDuckAiMode) NativeInputState.InputContext.DUCK_AI else NativeInputState.InputContext.BROWSER val position = if (isBottom) NativeInputState.InputPosition.BOTTOM else NativeInputState.InputPosition.TOP widgetConfig.value = WidgetConfig(inputContext = context, inputPosition = position) replayPendingState(tabId) + // An "open Duck.ai for image generation" launch preselects image generation on the new tab. + // The image-capable model was already selected when the launch was requested. + if (isDuckAiMode && forceImageGeneration) { + nativeInputStatePublisher.update(tabId) { it.copy(selectedTool = Tool.IMAGE_GENERATION.rawValue) } + } } private fun replayPendingState(tabId: String) { diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index 004e4617c3f0..b6a1d7a5d3fc 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 @@ -185,7 +185,7 @@ interface NativeInputWidget { onPageContextRemoved: () -> Unit, ) fun storePendingPrompt(query: String) - fun configure(tabId: String, isDuckAiMode: Boolean, isBottom: Boolean) + fun configure(tabId: String, isDuckAiMode: Boolean, isBottom: Boolean, forceImageGeneration: Boolean = false) fun configureContextual(tabId: String) fun configureForEdit(sessionId: String) fun adoptEditAttachments(images: List, files: List) @@ -1677,10 +1677,10 @@ class NativeInputModeWidget @JvmOverloads constructor( optionsView?.clearSelection() } - override fun configure(tabId: String, isDuckAiMode: Boolean, isBottom: Boolean) { + override fun configure(tabId: String, isDuckAiMode: Boolean, isBottom: Boolean, forceImageGeneration: Boolean) { activeTabId = tabId doOnAttach { - viewModel.configure(tabId, isDuckAiMode, isBottom) + viewModel.configure(tabId, isDuckAiMode, isBottom, forceImageGeneration) if (isDuckAiMode) selectChatTab() } } diff --git a/duckchat/duckchat-impl/src/main/res/drawable/ic_shield_check_24.xml b/duckchat/duckchat-impl/src/main/res/drawable/ic_shield_check_24.xml new file mode 100644 index 000000000000..f1ad8db0de68 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/drawable/ic_shield_check_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/duckchat/duckchat-impl/src/main/res/layout/popup_chat_history_overflow.xml b/duckchat/duckchat-impl/src/main/res/layout/popup_chat_history_overflow.xml new file mode 100644 index 000000000000..e0cbc58746e3 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/popup_chat_history_overflow.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/duckchat/duckchat-impl/src/main/res/layout/popup_chat_menu.xml b/duckchat/duckchat-impl/src/main/res/layout/popup_chat_menu.xml index 1163a6bed715..0170af2816c8 100644 --- a/duckchat/duckchat-impl/src/main/res/layout/popup_chat_menu.xml +++ b/duckchat/duckchat-impl/src/main/res/layout/popup_chat_menu.xml @@ -35,6 +35,14 @@ app:primaryText="@string/chatMenuPopupNewVoiceChat" app:leadingIcon="@drawable/ic_voice_24" /> + + + + diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index 1b53bb52c004..2246c8474f1f 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -39,4 +39,8 @@ Turn Duck.ai On Keep Duck.ai Off + + + Select Chats + Chat Protection 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 ed9632a3e497..7c39516b31a0 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 @@ -40,6 +40,10 @@ import com.duckduckgo.duckchat.api.InputMode import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.impl.feature.AIChatImageUploadFeature import com.duckduckgo.duckchat.impl.feature.DuckChatFeature +import com.duckduckgo.duckchat.impl.models.AIChatModel +import com.duckduckgo.duckchat.impl.models.DuckAiModelManager +import com.duckduckgo.duckchat.impl.models.ModelState +import com.duckduckgo.duckchat.impl.models.Tool import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.repository.AddressBarPickerAttributionRepository import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository @@ -109,6 +113,7 @@ class RealDuckChatTest { private val mockVoiceSessionStateManager: VoiceSessionStateManager = mock() private val chatSuggestionsStore: ChatSuggestionsStore = mock() private val mockDuckAiTabSessionRepository: DuckAiTabSessionRepository = mock() + private val mockDuckAiModelManager: DuckAiModelManager = mock() private lateinit var testee: RealDuckChat @@ -153,11 +158,12 @@ class RealDuckChatTest { mockVoiceSessionStateManager, chatSuggestionsStore, mockDuckAiTabSessionRepository, + mockDuckAiModelManager, ), ) coroutineRule.testScope.advanceUntilIdle() - whenever(mockBrowserNav.openDuckChat(any(), any(), any())).thenReturn(mockIntent) + whenever(mockBrowserNav.openDuckChat(any(), any(), any(), any())).thenReturn(mockIntent) whenever(mockBrowserNav.closeDuckChat(any())).thenReturn(mockIntent) } @@ -1974,6 +1980,67 @@ class RealDuckChatTest { assertFalse(results[1]) } + @Test + fun whenOpenDuckChatImageGenerationAndCurrentModelSupportsItThenModelNotSwitchedAndOpensWithImageGenerationForced() = runTest { + val current = aiModel("m1", tools = listOf(Tool.IMAGE_GENERATION)) + whenever(mockDuckAiModelManager.modelState).thenReturn( + MutableStateFlow(ModelState(models = listOf(current), selectedModelId = "m1")), + ) + + testee.openDuckChatImageGeneration(DuckChatEntryPoint.BROWSING_MENU_WEBPAGE) + coroutineRule.testScope.advanceUntilIdle() + + verify(mockDuckAiModelManager, never()).selectModel(any()) + verify(mockBrowserNav).openDuckChat(any(), any(), any(), forceImageGeneration = eq(true)) + verify(mockContext).startActivity(mockIntent) + } + + @Test + fun whenOpenDuckChatImageGenerationAndCurrentModelUnsupportedThenSwitchesToCapableModelAndForcesImageGeneration() = runTest { + val current = aiModel("m1") + val capable = aiModel("m2", tools = listOf(Tool.IMAGE_GENERATION)) + whenever(mockDuckAiModelManager.modelState).thenReturn( + MutableStateFlow(ModelState(models = listOf(current, capable), selectedModelId = "m1")), + ) + + testee.openDuckChatImageGeneration(DuckChatEntryPoint.BROWSING_MENU_WEBPAGE) + coroutineRule.testScope.advanceUntilIdle() + + verify(mockDuckAiModelManager).selectModel(capable) + verify(mockBrowserNav).openDuckChat(any(), any(), any(), forceImageGeneration = eq(true)) + verify(mockContext).startActivity(mockIntent) + } + + @Test + fun whenOpenDuckChatImageGenerationAndNoAccessibleImageCapableModelThenDoesNotForceImageGenerationButStillOpens() = runTest { + val current = aiModel("m1") + val inaccessible = aiModel("m2", isAccessible = false, tools = listOf(Tool.IMAGE_GENERATION)) + whenever(mockDuckAiModelManager.modelState).thenReturn( + MutableStateFlow(ModelState(models = listOf(current, inaccessible), selectedModelId = "m1")), + ) + + testee.openDuckChatImageGeneration(DuckChatEntryPoint.BROWSING_MENU_WEBPAGE) + coroutineRule.testScope.advanceUntilIdle() + + verify(mockDuckAiModelManager, never()).selectModel(any()) + verify(mockBrowserNav).openDuckChat(any(), any(), any(), forceImageGeneration = eq(false)) + verify(mockContext).startActivity(mockIntent) + } + + private fun aiModel( + id: String, + isAccessible: Boolean = true, + tools: List = emptyList(), + ) = AIChatModel( + id = id, + name = id, + displayName = id, + shortName = id, + accessTier = emptyList(), + isAccessible = isAccessible, + supportedTools = tools, + ) + private suspend fun enableChatHistoryFlags() { duckChatFeature.self().setRawStoredState(State(enable = true)) duckChatFeature.useNativeStorageChatData().setRawStoredState(State(enable = true)) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt index 7608f3f45ed2..32d84dc53186 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt @@ -2729,6 +2729,7 @@ class DuckChatContextualViewModelTest { override suspend fun hasUserEnabledChatHistory(): Boolean = false override fun observeHasChatSuggestions(): Flow = emptyFlow() override suspend fun onAddressBarPickerDuckAiSelected() = Unit + override fun openDuckChatImageGeneration(entryPoint: DuckChatEntryPoint) = Unit } private class FakeDuckChatContextualDataStore : DuckChatContextualDataStore { diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModelTest.kt index a8fa97aa2ad6..b457eb16edad 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualWebViewViewModelTest.kt @@ -665,6 +665,7 @@ class DuckChatContextualWebViewViewModelTest { override suspend fun hasUserEnabledChatHistory(): Boolean = false override fun observeHasChatSuggestions(): Flow = emptyFlow() override suspend fun onAddressBarPickerDuckAiSelected() = Unit + override fun openDuckChatImageGeneration(entryPoint: DuckChatEntryPoint) = Unit } private class FakeDuckChatContextualDataStore : DuckChatContextualDataStore { diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt index ee1a099f38dc..9b489be4e3f7 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.duckchat.impl.history +import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.duckduckgo.app.statistics.pixels.Pixel @@ -41,14 +42,17 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +@RunWith(AndroidJUnit4::class) class ChatHistoryViewModelTest { @get:Rule @@ -441,6 +445,38 @@ class ChatHistoryViewModelTest { } } + @Test + fun `onChatsProtectionClicked while on a duck ai chat tab reuses that tab`() = + coroutineRule.testScope.runTest { + whenever(tabRepository.getSelectedTab()).thenReturn(TabEntity(tabId = "chat-tab", url = "https://duck.ai/chat")) + + viewModel.navigationEvents.test { + viewModel.onChatsProtectionClicked() + + val event = awaitItem() as ChatHistoryViewModel.NavigationEvent.OpenChatProtection + assertEquals("https://duck.ai/chat?chatProtection=open", event.url) + assertEquals("chat-tab", event.sourceTabId) + assertFalse(event.inNewTab) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onChatsProtectionClicked from any other tab opens chat protection in a new tab`() = + coroutineRule.testScope.runTest { + whenever(tabRepository.getSelectedTab()).thenReturn(TabEntity(tabId = "web-tab", url = "https://example.com")) + + viewModel.navigationEvents.test { + viewModel.onChatsProtectionClicked() + + val event = awaitItem() as ChatHistoryViewModel.NavigationEvent.OpenChatProtection + assertEquals("https://duck.ai/chat?chatProtection=open", event.url) + assertEquals("web-tab", event.sourceTabId) + assertTrue(event.inNewTab) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `onChatRowLongClicked in default mode enters select mode with the row pre-selected`() = runTest { source.value = listOf(item("a"), item("b")) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt index 60ce39968161..462e1a0e2ce0 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt @@ -135,6 +135,8 @@ class FakeDuckChat( override suspend fun onAddressBarPickerDuckAiSelected() { } + override fun openDuckChatImageGeneration(entryPoint: DuckChatEntryPoint) { } + fun setEnabled(enabled: Boolean) { this.enabled = enabled } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt index 2b40d9163a01..4ef06b138dd0 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt @@ -80,7 +80,7 @@ class FakeDuckChatInternal( override fun getDuckChatSettingsUrl(): String = "https://duck.ai?settings=open" - override fun isDuckChatUrl(uri: Uri): Boolean = false + override fun isDuckChatUrl(uri: Uri): Boolean = uri.host == "duck.ai" override suspend fun wasOpenedBefore(): Boolean = false @@ -233,8 +233,12 @@ class FakeDuckChatInternal( override suspend fun onAddressBarPickerDuckAiSelected() { } + override fun openDuckChatImageGeneration(entryPoint: DuckChatEntryPoint) { } + override fun buildChatUrl(chatId: String): String = "https://duck.ai?chatID=$chatId" + override fun getChatProtectionUrl(): String = "https://duck.ai/chat?chatProtection=open" + private val _defaultTogglePosition = MutableStateFlow(null) override suspend fun setDefaultTogglePosition(position: DefaultTogglePosition) { diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt index 72f98504e98c..5ca28767ae9d 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt @@ -437,6 +437,30 @@ class NativeInputModeWidgetViewModelTest { assertEquals(NativeInputState.ToggleSelection.DUCK_AI, state.toggleSelection) } + @Test + fun whenConfigureDuckAiModeAndForceImageGenerationThenImageToolSelectedForTab() = runTest { + testee.configure(tabId = "tab-1", isDuckAiMode = true, isBottom = false, forceImageGeneration = true) + + assertEquals( + Tool.IMAGE_GENERATION.rawValue, + nativeInputStateProvider.stateForTab("tab-1").value.selectedTool, + ) + } + + @Test + fun whenConfigureDuckAiModeAndNotForceImageGenerationThenNoToolSelected() = runTest { + testee.configure(tabId = "tab-1", isDuckAiMode = true, isBottom = false, forceImageGeneration = false) + + assertNull(nativeInputStateProvider.stateForTab("tab-1").value.selectedTool) + } + + @Test + fun whenConfigureBrowserModeAndForceImageGenerationThenNoToolSelected() = runTest { + testee.configure(tabId = "tab-1", isDuckAiMode = false, isBottom = false, forceImageGeneration = true) + + assertNull(nativeInputStateProvider.stateForTab("tab-1").value.selectedTool) + } + @Test fun whenEditRequestTargetsThisTabThenItIsEmitted() = runTest { testee.configure(tabId = "tab-1", isDuckAiMode = true, isBottom = false)