diff --git a/android-design-system/design-system/src/main/res/drawable/duck_ai_prompt_background.xml b/android-design-system/design-system/src/main/res/drawable/duck_ai_prompt_background.xml
new file mode 100644
index 000000000000..17a31333d541
--- /dev/null
+++ b/android-design-system/design-system/src/main/res/drawable/duck_ai_prompt_background.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-design-system/design-system/src/main/res/drawable/ic_arrow_down_right_16.xml b/android-design-system/design-system/src/main/res/drawable/ic_arrow_down_right_16.xml
new file mode 100644
index 000000000000..7f06497ffaa0
--- /dev/null
+++ b/android-design-system/design-system/src/main/res/drawable/ic_arrow_down_right_16.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/android-design-system/design-system/src/main/res/drawable/ic_duck_ai_color_24.xml b/android-design-system/design-system/src/main/res/drawable/ic_duck_ai_color_24.xml
new file mode 100644
index 000000000000..9cb48027adac
--- /dev/null
+++ b/android-design-system/design-system/src/main/res/drawable/ic_duck_ai_color_24.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-design-system/design-system/src/main/res/drawable/ic_expand_24.xml b/android-design-system/design-system/src/main/res/drawable/ic_expand_24.xml
new file mode 100644
index 000000000000..bdb285c02e50
--- /dev/null
+++ b/android-design-system/design-system/src/main/res/drawable/ic_expand_24.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt
index c1bcaacb6f70..79aa6c58bcbf 100644
--- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt
+++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt
@@ -462,6 +462,8 @@ class BrowserTabViewModelTest {
private val mockDuckAiFeatureStateFullScreenModeFlow = MutableStateFlow(false)
+ private val mockDuckAiContextualModeFlow = MutableStateFlow(false)
+
private val mockExternalIntentProcessingState: ExternalIntentProcessingState = mock()
private val mockVpnMenuStateProvider: VpnMenuStateProvider = mock()
@@ -694,6 +696,7 @@ class BrowserTabViewModelTest {
whenever(mockDuckAiFeatureState.showInputScreen).thenReturn(mockDuckAiFeatureStateInputScreenFlow)
whenever(mockDuckAiFeatureState.showInputScreenAutomaticallyOnNewTab).thenReturn(mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow)
whenever(mockDuckAiFeatureState.showFullScreenMode).thenReturn(mockDuckAiFeatureStateFullScreenModeFlow)
+ whenever(mockDuckAiFeatureState.showContextualMode).thenReturn(mockDuckAiContextualModeFlow)
whenever(mockExternalIntentProcessingState.hasPendingTabLaunch).thenReturn(mockHasPendingTabLaunchFlow)
whenever(mockExternalIntentProcessingState.hasPendingDuckAiOpen).thenReturn(mockHasPendingDuckAiOpenFlow)
whenever(mockVpnMenuStateProvider.getVpnMenuState()).thenReturn(flowOf(VpnMenuState.Hidden))
@@ -8362,4 +8365,33 @@ class BrowserTabViewModelTest {
verify(mockPixel).fire(DuckChatPixelName.PRODUCT_TELEMETRY_SURFACE_KEYBOARD_USAGE)
verify(mockPixel).fire(DuckChatPixelName.PRODUCT_TELEMETRY_SURFACE_KEYBOARD_USAGE_DAILY, type = Daily())
}
+
+ @Test
+ fun whenOnDuckChatOmnibarButtonClickedAndContextualModeEnabledAndNotNTPThenCommandSent() = runTest {
+ mockDuckAiContextualModeFlow.emit(true)
+
+ testee.onDuckChatOmnibarButtonClicked(query = "example", hasFocus = false, isNtp = false)
+
+ verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
+ assertTrue(commandCaptor.lastValue is Command.ShowDuckAIContextualMode)
+ }
+
+ @Test
+ fun whenOnDuckChatOmnibarButtonClickedAndContextualModeEnabledAndNTPThenContextualNotCalled() = runTest {
+ val duckAIUrl = "https://duckduckgo.com/?q=test"
+
+ mockDuckAiContextualModeFlow.emit(true)
+ mockDuckAiFeatureStateFullScreenModeFlow.emit(true)
+
+ whenever(mockDuckChat.getDuckChatUrl(any(), any())).thenReturn(duckAIUrl)
+ whenever(mockOmnibarConverter.convertQueryToUrl(duckAIUrl, null)).thenReturn(duckAIUrl)
+
+ testee.onDuckChatOmnibarButtonClicked(query = "example", hasFocus = false, isNtp = true)
+
+ verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
+ val command = commandCaptor.lastValue as Navigate
+ assertEquals(duckAIUrl, command.url)
+
+ verify(mockDuckChat, never()).openDuckChat()
+ }
}
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 7d591b6af47c..c4522ce4e8da 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
@@ -121,6 +121,7 @@ import com.duckduckgo.app.browser.customtabs.CustomTabViewModel.Companion.CUSTOM
import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding
import com.duckduckgo.app.browser.databinding.HttpAuthenticationBinding
import com.duckduckgo.app.browser.downloader.BlobConverterInjector
+import com.duckduckgo.app.browser.duckchat.DuckChatContextualBottomSheetFactory
import com.duckduckgo.app.browser.favicon.FaviconManager
import com.duckduckgo.app.browser.filechooser.FileChooserIntentBuilder
import com.duckduckgo.app.browser.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher
@@ -296,6 +297,7 @@ import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityResultCodes
import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityResultParams
import com.duckduckgo.duckchat.api.inputscreen.InputScreenBrowserButtonsConfig
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName
+import com.duckduckgo.duckchat.impl.ui.DuckChatContextualBottomSheet
import com.duckduckgo.duckplayer.api.DuckPlayer
import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams
import com.duckduckgo.js.messaging.api.JsCallbackData
@@ -593,6 +595,9 @@ class BrowserTabFragment :
@Inject
lateinit var browserMenuViewStateFactory: BrowserMenuViewStateFactory
+ @Inject
+ lateinit var duckChatContextualBottomSheetFactory: DuckChatContextualBottomSheetFactory
+
/**
* 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
@@ -870,6 +875,8 @@ class BrowserTabFragment :
private var automaticFireproofDialog: DaxAlertDialog? = null
+ private var duckChatContextualSheet: DuckChatContextualBottomSheet? = null
+
private var webShareRequest =
registerForActivityResult(WebShareChooser()) {
contentScopeScripts.onResponse(it)
@@ -2409,6 +2416,7 @@ class BrowserTabFragment :
is Command.PageStarted -> onPageStarted()
is Command.EnableDuckAIFullScreen -> showDuckAI(it.browserViewState)
is Command.DisableDuckAIFullScreen -> omnibar.setViewMode(ViewMode.Browser(it.url))
+ is Command.ShowDuckAIContextualMode -> showDuckChatBottomSheet()
}
}
@@ -3105,10 +3113,6 @@ class BrowserTabFragment :
}
override fun onDuckChatButtonPressed() {
- if (!duckAiFeatureState.showInputScreen.value) {
- pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED)
- pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED_DAILY, type = Daily())
- }
val hasFocus = omnibar.omnibarTextInput.hasFocus()
val isNtp = omnibar.viewMode == ViewMode.NewTab
onOmnibarDuckChatPressed(query = omnibar.getText(), hasFocus = hasFocus, isNtp = isNtp)
@@ -3126,6 +3130,13 @@ class BrowserTabFragment :
)
}
+ private fun showDuckChatBottomSheet() {
+ if (duckChatContextualSheet == null) {
+ duckChatContextualSheet = duckChatContextualBottomSheetFactory.create()
+ }
+ duckChatContextualSheet?.show(childFragmentManager, DuckChatContextualBottomSheet.TAG)
+ }
+
private fun configureOmnibarTextInput() {
omnibar.addTextListener(
object : TextListener {
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 b97ec89bd532..4837a2eb29c3 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
@@ -4500,18 +4500,31 @@ class BrowserTabViewModel @Inject constructor(
command.value = HideKeyboardForChat
}
- if (duckAiFeatureState.showFullScreenMode.value) {
- val url = when {
- hasFocus && isNtp && query.isNullOrBlank() -> duckChat.getDuckChatUrl(query ?: "", false)
- hasFocus -> duckChat.getDuckChatUrl(query ?: "", true)
- else -> duckChat.getDuckChatUrl(query ?: "", false)
+ if (!duckAiFeatureState.showInputScreen.value) {
+ pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED)
+ pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED_DAILY, type = Daily())
+ }
+
+ when {
+ duckAiFeatureState.showContextualMode.value && !isNtp -> {
+ command.value = Command.ShowDuckAIContextualMode
}
- onUserSubmittedQuery(url)
- } else {
- when {
- hasFocus && isNtp && query.isNullOrBlank() -> duckChat.openDuckChat()
- hasFocus -> duckChat.openDuckChatWithAutoPrompt(query ?: "")
- else -> duckChat.openDuckChat()
+
+ duckAiFeatureState.showFullScreenMode.value -> {
+ val url = when {
+ hasFocus && isNtp && query.isNullOrBlank() -> duckChat.getDuckChatUrl(query ?: "", false)
+ hasFocus -> duckChat.getDuckChatUrl(query ?: "", true)
+ else -> duckChat.getDuckChatUrl(query ?: "", false)
+ }
+ onUserSubmittedQuery(url)
+ }
+
+ else -> {
+ when {
+ hasFocus && isNtp && query.isNullOrBlank() -> duckChat.openDuckChat()
+ hasFocus -> duckChat.openDuckChatWithAutoPrompt(query ?: "")
+ else -> duckChat.openDuckChat()
+ }
}
}
}
diff --git a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt
index ce0b7f6e4593..3a776893636d 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt
@@ -501,4 +501,6 @@ sealed class Command {
data class EnableDuckAIFullScreen(val browserViewState: BrowserViewState) : Command()
data class DisableDuckAIFullScreen(val url: String) : Command()
+
+ data object ShowDuckAIContextualMode : Command()
}
diff --git a/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatContextualBottomSheetFactory.kt b/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatContextualBottomSheetFactory.kt
new file mode 100644
index 000000000000..41516cd120fc
--- /dev/null
+++ b/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatContextualBottomSheetFactory.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2025 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.app.browser.duckchat
+
+import com.duckduckgo.app.di.AppCoroutineScope
+import com.duckduckgo.app.tabs.BrowserNav
+import com.duckduckgo.appbuildconfig.api.AppBuildConfig
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.common.utils.FragmentViewModelFactory
+import com.duckduckgo.di.scopes.FragmentScope
+import com.duckduckgo.downloads.api.DownloadStateListener
+import com.duckduckgo.downloads.api.DownloadsFileActions
+import com.duckduckgo.downloads.api.FileDownloader
+import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.impl.feature.AIChatDownloadFeature
+import com.duckduckgo.duckchat.impl.helper.DuckChatJSHelper
+import com.duckduckgo.duckchat.impl.ui.DuckChatContextualBottomSheet
+import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewClient
+import com.duckduckgo.duckchat.impl.ui.SubscriptionsHandler
+import com.duckduckgo.js.messaging.api.JsMessaging
+import com.squareup.anvil.annotations.ContributesBinding
+import dagger.SingleInstanceIn
+import kotlinx.coroutines.CoroutineScope
+import javax.inject.Inject
+import javax.inject.Named
+
+interface DuckChatContextualBottomSheetFactory {
+ fun create(): DuckChatContextualBottomSheet
+}
+
+@ContributesBinding(scope = FragmentScope::class)
+@SingleInstanceIn(scope = FragmentScope::class)
+class DuckChatContextualBottomSheetFactoryImpl @Inject constructor() : DuckChatContextualBottomSheetFactory {
+
+ @Inject
+ lateinit var viewModelFactory: FragmentViewModelFactory
+
+ @Inject
+ lateinit var duckChatWebViewClient: DuckChatWebViewClient
+
+ @Inject
+ @Named("ContentScopeScripts")
+ lateinit var contentScopeScripts: JsMessaging
+
+ @Inject
+ lateinit var duckChatJSHelper: DuckChatJSHelper
+
+ @Inject
+ lateinit var subscriptionsHandler: SubscriptionsHandler
+
+ @Inject
+ @AppCoroutineScope
+ lateinit var appCoroutineScope: CoroutineScope
+
+ @Inject
+ lateinit var dispatcherProvider: DispatcherProvider
+
+ @Inject
+ lateinit var browserNav: BrowserNav
+
+ @Inject
+ lateinit var appBuildConfig: AppBuildConfig
+
+ @Inject
+ lateinit var fileDownloader: FileDownloader
+
+ @Inject
+ lateinit var downloadCallback: DownloadStateListener
+
+ @Inject
+ lateinit var downloadsFileActions: DownloadsFileActions
+
+ @Inject
+ lateinit var aiChatDownloadFeature: AIChatDownloadFeature
+
+ @Inject
+ lateinit var duckChat: DuckChat
+
+ override fun create(): DuckChatContextualBottomSheet {
+ return DuckChatContextualBottomSheet(
+ viewModelFactory = viewModelFactory,
+ webViewClient = duckChatWebViewClient,
+ contentScopeScripts = contentScopeScripts,
+ duckChatJSHelper = duckChatJSHelper,
+ subscriptionsHandler = subscriptionsHandler,
+ appCoroutineScope = appCoroutineScope,
+ dispatcherProvider = dispatcherProvider,
+ browserNav = browserNav,
+ appBuildConfig = appBuildConfig,
+ fileDownloader = fileDownloader,
+ downloadCallback = downloadCallback,
+ downloadsFileActions = downloadsFileActions,
+ aiChatDownloadFeature = aiChatDownloadFeature,
+ duckChat = duckChat,
+ )
+ }
+}
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 8cfc9d81c893..c721b793ee29 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
@@ -140,6 +140,6 @@ interface DuckChatFeature {
/**
* @return `true` when the contextual mode is enabled
*/
- @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
+ @Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun contextualMode(): Toggle
}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatContextualBottomSheet.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatContextualBottomSheet.kt
new file mode 100644
index 000000000000..77e35b766e85
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatContextualBottomSheet.kt
@@ -0,0 +1,471 @@
+/*
+ * Copyright (c) 2025 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
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Dialog
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.os.Environment
+import android.os.Message
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.WebChromeClient
+import android.webkit.WebSettings
+import android.webkit.WebView
+import androidx.annotation.AnyThread
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.doOnLayout
+import androidx.core.view.updatePadding
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import com.duckduckgo.app.tabs.BrowserNav
+import com.duckduckgo.appbuildconfig.api.AppBuildConfig
+import com.duckduckgo.common.ui.view.gone
+import com.duckduckgo.common.ui.view.makeSnackbarWithNoBottomInset
+import com.duckduckgo.common.ui.view.show
+import com.duckduckgo.common.utils.ConflatedJob
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.common.utils.FragmentViewModelFactory
+import com.duckduckgo.common.utils.extensions.hideKeyboard
+import com.duckduckgo.downloads.api.DOWNLOAD_SNACKBAR_DELAY
+import com.duckduckgo.downloads.api.DOWNLOAD_SNACKBAR_LENGTH
+import com.duckduckgo.downloads.api.DownloadCommand
+import com.duckduckgo.downloads.api.DownloadConfirmationDialogListener
+import com.duckduckgo.downloads.api.DownloadStateListener
+import com.duckduckgo.downloads.api.DownloadsFileActions
+import com.duckduckgo.downloads.api.FileDownloader
+import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload
+import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.impl.R
+import com.duckduckgo.duckchat.impl.databinding.BottomSheetDuckAiContextualBinding
+import com.duckduckgo.duckchat.impl.feature.AIChatDownloadFeature
+import com.duckduckgo.duckchat.impl.helper.DuckChatJSHelper
+import com.duckduckgo.duckchat.impl.helper.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME
+import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment.Companion.KEY_DUCK_AI_URL
+import com.duckduckgo.js.messaging.api.JsMessageCallback
+import com.duckduckgo.js.messaging.api.JsMessaging
+import com.duckduckgo.js.messaging.api.SubscriptionEventData
+import com.duckduckgo.subscriptions.api.SUBSCRIPTIONS_FEATURE_NAME
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.cancellable
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import logcat.logcat
+import org.json.JSONObject
+import java.io.File
+
+class DuckChatContextualBottomSheet(
+ private val viewModelFactory: FragmentViewModelFactory,
+ private val webViewClient: DuckChatWebViewClient,
+ private val contentScopeScripts: JsMessaging,
+ private val duckChatJSHelper: DuckChatJSHelper,
+ private val subscriptionsHandler: SubscriptionsHandler,
+ private val appCoroutineScope: CoroutineScope,
+ private val dispatcherProvider: DispatcherProvider,
+ private val browserNav: BrowserNav,
+ private val appBuildConfig: AppBuildConfig,
+ private val fileDownloader: FileDownloader,
+ private val downloadCallback: DownloadStateListener,
+ private val downloadsFileActions: DownloadsFileActions,
+ private val aiChatDownloadFeature: AIChatDownloadFeature,
+ private val duckChat: DuckChat,
+) : BottomSheetDialogFragment(), DownloadConfirmationDialogListener {
+
+ // this will go in the viewmodel
+ private enum class SheetMode {
+ INPUT,
+ WEBVIEW,
+ }
+
+ private var sheetMode = SheetMode.INPUT
+
+ private var _binding: BottomSheetDuckAiContextualBinding? = null
+ private val binding get() = _binding!!
+
+ private val viewModel: DuckChatWebViewViewModel by lazy {
+ ViewModelProvider(this, viewModelFactory)[DuckChatWebViewViewModel::class.java]
+ }
+
+ private var pendingFileDownload: PendingFileDownload? = null
+ private val downloadMessagesJob = ConflatedJob()
+ private var isExpanded = false
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ logcat { "Duck.ai Contextual: onCreateDialog" }
+ val bottomSheetDialog = BottomSheetDialog(requireContext(), R.style.DuckChatBottomSheetDialogTheme)
+
+ bottomSheetDialog.window?.let {
+ ViewCompat.setOnApplyWindowInsetsListener(it.decorView) { view, insets ->
+ if (!isExpanded) {
+ val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
+ val systemBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
+ val extraMargin = (imeBottom - systemBottom).coerceAtLeast(0)
+
+ view.updatePadding(bottom = extraMargin)
+ }
+ insets
+ }
+ ViewCompat.requestApplyInsets(it.decorView)
+ }
+
+ bottomSheetDialog.let {
+ it.behavior.addBottomSheetCallback(
+ object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onStateChanged(
+ bottomSheet: View,
+ newState: Int,
+ ) {
+ if (newState == BottomSheetBehavior.STATE_EXPANDED) {
+ logcat { "Duck.ai Contextual: STATE_EXPANDED" }
+ it.behavior.skipCollapsed = true
+ isExpanded = true
+ binding.simpleWebview.show()
+ }
+ }
+
+ override fun onSlide(
+ bottomSheet: View,
+ slideOffset: Float,
+ ) {
+ }
+ },
+ )
+ }
+ return bottomSheetDialog
+ }
+
+ override fun onDestroyView() {
+ logcat { "Duck.ai Contextual: onDestroyView" }
+ super.onDestroyView()
+ _binding = null
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ logcat { "Duck.ai Contextual: onCreateView" }
+ _binding = BottomSheetDuckAiContextualBinding.inflate(inflater, container, false)
+ configureViews(binding)
+ return binding.root
+ }
+
+ private fun configureViews(binding: BottomSheetDuckAiContextualBinding) {
+ val bottomSheetDialog = dialog as? BottomSheetDialog
+ bottomSheetDialog?.let {
+ binding.root.doOnLayout {
+ val peekHeight = calculatePeekHeight(binding)
+ if (peekHeight > 0) {
+ bottomSheetDialog.behavior.peekHeight = peekHeight
+ bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_COLLAPSED
+ }
+ }
+ }
+ }
+
+ private fun configureDialogButtons(binding: BottomSheetDuckAiContextualBinding) {
+ binding.contextualClose.setOnClickListener {
+ val bottomSheetDialog = dialog as? BottomSheetDialog
+ bottomSheetDialog?.let {
+ bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_HIDDEN
+ }
+ }
+ binding.actionSend.setOnClickListener { showDuckAi() }
+ }
+
+ private fun showDuckAi() {
+ val prompt = binding.inputField.text.toString()
+ if (prompt.isNotEmpty()) {
+ val url = duckChat.getDuckChatUrl(prompt, true)
+ binding.simpleWebview.loadUrl(url)
+ }
+
+ sheetMode = SheetMode.WEBVIEW
+
+ hideKeyboard(binding.inputField)
+ binding.inputModeWidgetCard.gone()
+ binding.contextualModePrompts.gone()
+ binding.contextualWebViewContainer.show()
+ val bottomSheetDialog = dialog as? BottomSheetDialog
+ bottomSheetDialog?.let {
+ bottomSheetDialog.window?.decorView?.updatePadding(bottom = 0)
+ binding.root.doOnLayout {
+ bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ }
+ }
+ }
+
+ fun restoreWebState() {
+ binding.inputModeWidgetCard.gone()
+ binding.contextualModePrompts.gone()
+ binding.contextualWebViewContainer.show()
+ binding.simpleWebview.show()
+ (dialog as? BottomSheetDialog)?.behavior?.state = BottomSheetBehavior.STATE_EXPANDED
+ }
+
+ override fun onStart() {
+ super.onStart()
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+ logcat { "Duck.ai Contextual: onViewCreated" }
+
+ configureDialogButtons(binding)
+
+ binding.simpleWebview.let {
+ it.webViewClient = webViewClient
+ it.webChromeClient = object : WebChromeClient() {
+ override fun onCreateWindow(
+ view: WebView?,
+ isDialog: Boolean,
+ isUserGesture: Boolean,
+ resultMsg: Message?,
+ ): Boolean {
+ view?.requestFocusNodeHref(resultMsg)
+ val newWindowUrl = resultMsg?.data?.getString("url")
+ if (newWindowUrl != null) {
+ if (viewModel.handleOnSameWebView(newWindowUrl)) {
+ binding.simpleWebview.loadUrl(newWindowUrl)
+ } else {
+ startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl))
+ }
+ return true
+ }
+ return false
+ }
+ }
+
+ it.settings.apply {
+ userAgentString = CUSTOM_UA
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ loadWithOverviewMode = true
+ useWideViewPort = true
+ builtInZoomControls = true
+ displayZoomControls = false
+ mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
+ setSupportMultipleWindows(true)
+ databaseEnabled = false
+ setSupportZoom(true)
+ }
+
+ it.setDownloadListener { url, _, contentDisposition, mimeType, _ ->
+ appCoroutineScope.launch(dispatcherProvider.io()) {
+ if (aiChatDownloadFeature.self().isEnabled()) {
+ requestFileDownload(url, contentDisposition, mimeType)
+ }
+ }
+ }
+
+ contentScopeScripts.register(
+ it,
+ object : JsMessageCallback() {
+ override fun process(
+ featureName: String,
+ method: String,
+ id: String?,
+ data: JSONObject?,
+ ) {
+ logcat { "Duck.ai: process $featureName $method $id $data" }
+ when (featureName) {
+ DUCK_CHAT_FEATURE_NAME -> {
+ appCoroutineScope.launch(dispatcherProvider.io()) {
+ duckChatJSHelper.processJsCallbackMessage(featureName, method, id, data)?.let { response ->
+ withContext(dispatcherProvider.main()) {
+ contentScopeScripts.onResponse(response)
+ }
+ }
+ }
+ }
+
+ SUBSCRIPTIONS_FEATURE_NAME -> {
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ requireActivity(),
+ appCoroutineScope,
+ contentScopeScripts,
+ )
+ }
+
+ else -> {}
+ }
+ }
+ },
+ )
+ }
+
+ if (sheetMode == SheetMode.WEBVIEW) {
+ restoreWebState()
+ }
+
+ val url = arguments?.getString(KEY_DUCK_AI_URL) ?: "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5"
+ binding.simpleWebview.loadUrl(url)
+
+ observeViewModel()
+ launchDownloadMessagesJob()
+ }
+
+ private fun observeViewModel() {
+ viewModel.commands
+ .onEach { command ->
+ when (command) {
+ is DuckChatWebViewViewModel.Command.SendSubscriptionAuthUpdateEvent -> {
+ val authUpdateEvent = SubscriptionEventData(
+ featureName = SUBSCRIPTIONS_FEATURE_NAME,
+ subscriptionName = "authUpdate",
+ params = JSONObject(),
+ )
+ contentScopeScripts.sendSubscriptionEvent(authUpdateEvent)
+ }
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun requestFileDownload(
+ url: String,
+ contentDisposition: String?,
+ mimeType: String,
+ ) {
+ pendingFileDownload = PendingFileDownload(
+ url = url,
+ contentDisposition = contentDisposition,
+ mimeType = mimeType,
+ subfolder = Environment.DIRECTORY_DOWNLOADS,
+ fileName = "duck.ai_${System.currentTimeMillis()}",
+ )
+
+ if (hasWriteStoragePermission()) {
+ downloadFile()
+ } else {
+ requestWriteStoragePermission()
+ }
+ }
+
+ private fun minSdk30(): Boolean {
+ return appBuildConfig.sdkInt >= 30
+ }
+
+ @Suppress("NewApi")
+ private fun hasWriteStoragePermission(): Boolean {
+ return minSdk30() ||
+ ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
+ }
+
+ private fun requestWriteStoragePermission() {
+ requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE)
+ }
+
+ @AnyThread
+ private fun downloadFile() {
+ val pendingDownload = pendingFileDownload ?: return
+
+ pendingFileDownload = null
+
+ continueDownload(pendingDownload)
+ }
+
+ override fun continueDownload(pendingFileDownload: PendingFileDownload) {
+ fileDownloader.enqueueDownload(pendingFileDownload)
+ }
+
+ override fun cancelDownload() {
+ // NOOP
+ }
+
+ private fun calculatePeekHeight(binding: BottomSheetDuckAiContextualBinding): Int {
+ val headerHeight = viewHeightWithMargins(binding.contextualModeButtons)
+ val promptsHeight = viewHeightWithMargins(binding.contextualModePrompts)
+ val inputHeight = viewHeightWithMargins(binding.inputModeWidgetCard)
+ val rootPadding = binding.root.paddingTop + binding.root.paddingBottom
+ return (headerHeight + promptsHeight + inputHeight + rootPadding).coerceAtLeast(0)
+ }
+
+ private fun viewHeightWithMargins(view: View): Int {
+ val layoutParams = view.layoutParams as? ViewGroup.MarginLayoutParams
+ val verticalMargins = (layoutParams?.topMargin ?: 0) + (layoutParams?.bottomMargin ?: 0)
+ val baseHeight = if (view.height > 0) view.height else view.measuredHeight
+ return baseHeight + verticalMargins
+ }
+
+ private fun launchDownloadMessagesJob() {
+ downloadMessagesJob += lifecycleScope.launch {
+ downloadCallback.commands().cancellable().collect {
+ processFileDownloadedCommand(it)
+ }
+ }
+ }
+
+ private fun processFileDownloadedCommand(command: DownloadCommand) {
+ when (command) {
+ is DownloadCommand.ShowDownloadStartedMessage -> downloadStarted(command)
+ is DownloadCommand.ShowDownloadFailedMessage -> downloadFailed(command)
+ is DownloadCommand.ShowDownloadSuccessMessage -> downloadSucceeded(command)
+ }
+ }
+
+ @SuppressLint("WrongConstant")
+ private fun downloadStarted(command: DownloadCommand.ShowDownloadStartedMessage) {
+ binding.simpleWebview.makeSnackbarWithNoBottomInset(getString(command.messageId, command.fileName), DOWNLOAD_SNACKBAR_LENGTH)?.show()
+ }
+
+ private fun downloadFailed(command: DownloadCommand.ShowDownloadFailedMessage) {
+ val downloadFailedSnackbar = binding.simpleWebview.makeSnackbarWithNoBottomInset(getString(command.messageId), Snackbar.LENGTH_LONG)
+ binding.simpleWebview.postDelayed({ downloadFailedSnackbar.show() }, DOWNLOAD_SNACKBAR_DELAY)
+ }
+
+ private fun downloadSucceeded(command: DownloadCommand.ShowDownloadSuccessMessage) {
+ val downloadSucceededSnackbar = binding.simpleWebview.makeSnackbarWithNoBottomInset(
+ getString(command.messageId, command.fileName),
+ Snackbar.LENGTH_LONG,
+ )
+ .apply {
+ this.setAction(R.string.duck_chat_download_finished_action_name) {
+ val result = downloadsFileActions.openFile(context, File(command.filePath))
+ if (!result) {
+ view.makeSnackbarWithNoBottomInset(getString(R.string.duck_chat_cannot_open_file_error_message), Snackbar.LENGTH_LONG).show()
+ }
+ }
+ }
+ binding.simpleWebview.postDelayed({ downloadSucceededSnackbar.show() }, DOWNLOAD_SNACKBAR_DELAY)
+ }
+
+ companion object {
+ const val TAG = "DuckChatBottomSheet"
+ private const val CUSTOM_UA =
+ "Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile DuckDuckGo/5 Safari/537.36"
+ private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt
index 31e487bfcab4..579d7716b947 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt
@@ -708,7 +708,7 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c
private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200
private const val CUSTOM_UA =
"Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile DuckDuckGo/5 Safari/537.36"
- private const val REQUEST_CODE_CHOOSE_FILE = 100
+ const val REQUEST_CODE_CHOOSE_FILE = 100
const val KEY_DUCK_AI_URL: String = "KEY_DUCK_AI_URL"
const val KEY_DUCK_AI_TABS: String = "KEY_DUCK_AI_TABS"
}
diff --git a/duckchat/duckchat-impl/src/main/res/layout/bottom_sheet_duck_ai_contextual.xml b/duckchat/duckchat-impl/src/main/res/layout/bottom_sheet_duck_ai_contextual.xml
new file mode 100644
index 000000000000..a1ecd7a6e5eb
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/res/layout/bottom_sheet_duck_ai_contextual.xml
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/duckchat/duckchat-impl/src/main/res/values/dimens.xml b/duckchat/duckchat-impl/src/main/res/values/dimens.xml
index bffad1683b6a..99d9023d631b 100644
--- a/duckchat/duckchat-impl/src/main/res/values/dimens.xml
+++ b/duckchat/duckchat-impl/src/main/res/values/dimens.xml
@@ -21,4 +21,5 @@
0dp
64dp
0.5dp
+ 80dp
\ No newline at end of file
diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
new file mode 100644
index 000000000000..f84557c92ca1
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+ Summarize this Page
+ Explain in simpler terms
+ Suggest related articles to explore
+
+
\ No newline at end of file
diff --git a/duckchat/duckchat-impl/src/main/res/values/widgets.xml b/duckchat/duckchat-impl/src/main/res/values/widgets.xml
new file mode 100644
index 000000000000..b279de26403a
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/res/values/widgets.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+