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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>()

private val pendingInputModeTargets = mutableMapOf<String, InputMode>()

private lateinit var renderer: BrowserStateRenderer

private val binding: ActivityBrowserBinding by viewBinding()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" }

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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())
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
)
}

/**
Expand Down Expand Up @@ -1668,6 +1697,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
action.sourceTabId,
action.skipHome,
action.isExternal,
action.inputModeTarget,
)
is PendingAction.OpenExistingTab -> openExistingTab(action.tabId)
}
Expand All @@ -1678,15 +1708,24 @@ open class BrowserActivity : DuckDuckGoActivity() {
sourceTabId: String?,
skipHome: Boolean,
isExternal: Boolean,
inputModeTarget: InputMode? = null,
) {
lifecycleScope.launch {
if (swipingTabsFeature.isEnabled) {
val tabId = tabManager.openNewTab(query, sourceTabId, skipHome)
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
}
}
}
}
Expand Down
57 changes: 43 additions & 14 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -662,6 +663,9 @@ class BrowserTabFragment :
@Inject
lateinit var duckChatContextual: DuckChatContextual

@Inject
lateinit var duckAiFeatureState: DuckAiFeatureState

@Inject
lateinit var newAddressBarPickerManager: NewAddressBarPickerManager

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1394,19 +1409,16 @@ class BrowserTabFragment :
}
}

private fun showNativeInput(query: String = "") {
private fun showNativeInput(query: String = "", forceImageGeneration: Boolean = false) {
nativeInputManager.showNativeInput(
tabId = tabId,
layoutInflater = layoutInflater,
lifecycleOwner = viewLifecycleOwner,
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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -3952,6 +3966,21 @@ class BrowserTabFragment :
chatMenuPopup.contentView
.findViewById<View>(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewFireTab)
.isVisible = fireModeAvailability.isAvailable()
val nativeSidebarEnabled = duckAiFeatureState.nativeDuckAiSidebar.value
chatMenuPopup.contentView
.findViewById<View>(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewImage)
.isVisible = nativeSidebarEnabled
chatMenuPopup.contentView
.findViewById<PopupMenuItemView>(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)
}

Expand Down Expand Up @@ -3988,7 +4017,7 @@ class BrowserTabFragment :

override fun onDuckAISidebarButtonPressed() {
pixel.fire(DuckChatPixelName.DUCK_CHAT_OMNIBAR_SIDEBAR_TAPPED)
viewModel.openDuckChatSidebar()
viewModel.onDuckChatSidebarButtonPressed()
}

override fun onDuckAIBackButtonPressed() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
Expand Down
Loading
Loading