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 985feecc15a9..dc1252d7aa8f 100644
--- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt
+++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt
@@ -456,6 +456,8 @@ class BrowserTabViewModelTest {
private val mockDuckAiFeatureStateFullScreenModeFlow = MutableStateFlow(false)
+ private val mockDuckAiContextualModeFlow = MutableStateFlow(false)
+
private val mockExternalIntentProcessingState: ExternalIntentProcessingState = mock()
private val mockVpnMenuStateProvider: VpnMenuStateProvider = mock()
@@ -678,6 +680,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))
@@ -8241,4 +8244,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 a8f81217f0f4..89330b452853 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
@@ -296,6 +296,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.DuckChatContextualFragment
import com.duckduckgo.duckplayer.api.DuckPlayer
import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams
import com.duckduckgo.js.messaging.api.JsCallbackData
@@ -368,6 +369,8 @@ class BrowserTabFragment :
EmailProtectionUserPromptListener {
private val supervisorJob = SupervisorJob()
+ private var duckAiContextualFragment: DuckChatContextualFragment? = null
+
override val coroutineContext: CoroutineContext
get() = supervisorJob + dispatchers.main()
@@ -864,6 +867,8 @@ class BrowserTabFragment :
private var automaticFireproofDialog: DaxAlertDialog? = null
+ private var duckChatContextualSheet: DuckChatContextualFragment? = null
+
private var webShareRequest =
registerForActivityResult(WebShareChooser()) {
contentScopeScripts.onResponse(it)
@@ -2401,6 +2406,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()
}
}
@@ -3104,10 +3110,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)
@@ -3125,6 +3127,38 @@ class BrowserTabFragment :
)
}
+ private fun showDuckChatBottomSheet() {
+ duckAiContextualFragment?.let { fragment ->
+ val transaction = childFragmentManager.beginTransaction()
+ transaction.show(fragment)
+ transaction.commit()
+ } ?: run {
+ val fragment = DuckChatContextualFragment()
+ duckAiContextualFragment = fragment
+ val transaction = childFragmentManager.beginTransaction()
+ transaction.replace(binding.duckAiFragmentContainer.id, fragment)
+ transaction.commit()
+ }
+
+ binding.duckAiFragmentContainer.show()
+
+ val bottomSheetBehavior = BottomSheetBehavior.from(binding.duckAiFragmentContainer)
+ bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
+ bottomSheetBehavior.isShouldRemoveExpandedCorners = false
+ bottomSheetBehavior.skipCollapsed = true
+ bottomSheetBehavior.isDraggable = true
+ bottomSheetBehavior.isHideable = true
+
+ bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (newState == BottomSheetBehavior.STATE_HIDDEN) {
+ binding.duckAiFragmentContainer.gone()
+ }
+ }
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {}
+ })
+ }
+
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 c57cf1c45681..bf4832f41e17 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
@@ -4469,18 +4469,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 261e0fed5936..f23e33629215 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
@@ -503,4 +503,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/res/layout/fragment_browser_tab.xml b/app/src/main/res/layout/fragment_browser_tab.xml
index d9e0aec9ddfc..849015dcece9 100644
--- a/app/src/main/res/layout/fragment_browser_tab.xml
+++ b/app/src/main/res/layout/fragment_browser_tab.xml
@@ -142,6 +142,16 @@
+
+
>? = null
+
+ private val root: ViewGroup by lazy { binding.root }
+
+ internal val simpleWebview: WebView by lazy { binding.simpleWebview }
+
+ // this will go in the viewmodel
+ private enum class SheetMode {
+ INPUT,
+ WEBVIEW,
+ }
+
+ private var sheetMode = SheetMode.WEBVIEW
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val url = arguments?.getString(KEY_DUCK_AI_URL) ?: "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5"
+
+ 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)) {
+ simpleWebview.loadUrl(newWindowUrl)
+ } else {
+ startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl))
+ }
+ return true
+ }
+ return false
+ }
+
+ override fun onShowFileChooser(
+ webView: WebView,
+ filePathCallback: ValueCallback>,
+ fileChooserParams: FileChooserParams,
+ ): Boolean {
+ return try {
+ showFileChooser(filePathCallback, fileChooserParams)
+ true
+ } catch (e: Throwable) {
+ // cancel the request using the documented way
+ filePathCallback.onReceiveValue(null)
+ throw e
+ }
+ }
+ }
+
+ 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()) {
+ if (response.method == METHOD_OPEN_KEYBOARD) {
+ simpleWebview.evaluateJavascript(
+ response.params.get(SELECTOR).toString(),
+ null,
+ )
+ showSoftKeyboard()
+ }
+ contentScopeScripts.onResponse(response)
+ }
+ }
+ }
+ }
+
+ SUBSCRIPTIONS_FEATURE_NAME -> {
+ subscriptionsHandler.handleSubscriptionsFeature(
+ featureName,
+ method,
+ id,
+ data,
+ requireActivity(),
+ appCoroutineScope,
+ contentScopeScripts,
+ )
+ }
+
+ else -> {}
+ }
+ }
+ },
+ )
+ }
+
+ url?.let {
+ simpleWebview.loadUrl(it)
+ }
+
+ externalCameraLauncher.registerForResult(this) {
+ when (it) {
+ is MediaCaptured -> pendingUploadTask?.onReceiveValue(arrayOf(Uri.fromFile(it.file)))
+ is CouldNotCapturePermissionDenied -> {
+ pendingUploadTask?.onReceiveValue(null)
+ externalCameraLauncher.showPermissionRationaleDialog(requireActivity(), it.inputAction)
+ }
+
+ is NoMediaCaptured -> pendingUploadTask?.onReceiveValue(null)
+ is ErrorAccessingMediaApp -> {
+ pendingUploadTask?.onReceiveValue(null)
+ Snackbar.make(root, it.messageId, BaseTransientBottomBar.LENGTH_SHORT).show()
+ }
+ }
+ pendingUploadTask = null
+ }
+ configureBottomSheet(view)
+ observeViewModel()
+ }
+
+ private fun configureBottomSheet(view: View) {
+ val parent = view.parent as? View ?: return
+ val bottomSheetBehavior = BottomSheetBehavior.from(parent)
+ binding.contextualClose.setOnClickListener {
+ bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
+ }
+ binding.contextualModeButtons.setOnClickListener { }
+ }
+
+ 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 launchInputScreen() {
+ // val isTopOmnibar = omnibar.omnibarType != OmnibarType.SINGLE_BOTTOM
+ // TODO: Support Bottom / Split omnibar
+ val isTopOmnibar = true
+ val tabs = arguments?.getInt(KEY_DUCK_AI_TABS) ?: 0
+ val intent =
+ globalActivityStarter.startIntent(
+ requireContext(),
+ InputScreenActivityParams(
+ query = "",
+ isTopOmnibar = true,
+ browserButtonsConfig = InputScreenBrowserButtonsConfig.Enabled(tabs = tabs),
+ ),
+ )
+ val enterTransition = browserAndInputScreenTransitionProvider.getInputScreenEnterAnimation(true)
+ val exitTransition = browserAndInputScreenTransitionProvider.getBrowserExitAnimation(isTopOmnibar)
+ val options =
+ ActivityOptionsCompat.makeCustomAnimation(
+ requireActivity(),
+ enterTransition,
+ exitTransition,
+ )
+ inputScreenLauncher.launch(intent, options)
+ }
+
+ private val inputScreenLauncher =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ val data = result.data
+ when (result.resultCode) {
+ InputScreenActivityResultCodes.NEW_SEARCH_REQUESTED -> {
+ data?.getStringExtra(InputScreenActivityResultParams.SEARCH_QUERY_PARAM)?.let { query ->
+ browseSharedViewModel.onSearchRequested(query)
+ }
+ }
+
+ InputScreenActivityResultCodes.SWITCH_TO_TAB_REQUESTED -> {
+ data?.getStringExtra(InputScreenActivityResultParams.TAB_ID_PARAM)?.let { tabId ->
+ browseSharedViewModel.openExistingTab(tabId)
+ }
+ }
+
+ InputScreenActivityResultCodes.MENU_REQUESTED -> {
+ }
+
+ InputScreenActivityResultCodes.TAB_SWITCHER_REQUESTED -> {
+ browseSharedViewModel.onTabSwitcherClicked()
+ }
+
+ InputScreenActivityResultCodes.FIRE_BUTTON_REQUESTED -> {
+ browseSharedViewModel.onFireButtonClicked()
+ }
+ }
+ }
+
+ data class FileChooserRequestedParams(
+ val filePickingMode: Int,
+ val acceptMimeTypes: List,
+ )
+
+ private fun showSoftKeyboard() {
+ simpleWebview.requestFocus()
+ val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ imm?.showSoftInput(simpleWebview, InputMethodManager.SHOW_IMPLICIT)
+ }
+
+ private fun hideSoftKeyboard() {
+ val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ imm?.hideSoftInputFromWindow(simpleWebview.windowToken, 0)
+ }
+
+ fun showFileChooser(
+ filePathCallback: ValueCallback>,
+ fileChooserParams: FileChooserParams,
+ ) {
+ val mimeTypes = convertAcceptTypesToMimeTypes(fileChooserParams.acceptTypes)
+ val fileChooserRequestedParams = FileChooserRequestedParams(fileChooserParams.mode, mimeTypes)
+ val cameraHardwareAvailable = cameraHardwareChecker.hasCameraHardware()
+
+ when {
+ fileChooserParams.isCaptureEnabled -> {
+ when {
+ acceptsOnly("image/", fileChooserParams.acceptTypes) && cameraHardwareAvailable ->
+ launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_IMAGE_CAPTURE)
+
+ acceptsOnly("video/", fileChooserParams.acceptTypes) && cameraHardwareAvailable ->
+ launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE)
+
+ acceptsOnly("audio/", fileChooserParams.acceptTypes) ->
+ launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.Audio.Media.RECORD_SOUND_ACTION)
+
+ else ->
+ launchFilePicker(filePathCallback, fileChooserRequestedParams)
+ }
+ }
+
+ fileChooserParams.acceptTypes.any { it.startsWith("image/") && cameraHardwareAvailable } ->
+ launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_IMAGE_CAPTURE)
+
+ fileChooserParams.acceptTypes.any { it.startsWith("video/") && cameraHardwareAvailable } ->
+ launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE)
+
+ else ->
+ launchFilePicker(filePathCallback, fileChooserRequestedParams)
+ }
+ }
+
+ private fun launchFilePicker(
+ filePathCallback: ValueCallback>,
+ fileChooserParams: FileChooserRequestedParams,
+ ) {
+ pendingUploadTask = filePathCallback
+ val canChooseMultipleFiles = fileChooserParams.filePickingMode == FileChooserParams.MODE_OPEN_MULTIPLE
+ val intent = fileChooserIntentBuilder.intent(fileChooserParams.acceptMimeTypes.toTypedArray(), canChooseMultipleFiles)
+ startActivityForResult(intent, REQUEST_CODE_CHOOSE_FILE)
+ }
+
+ private fun launchCameraCapture(
+ filePathCallback: ValueCallback>,
+ fileChooserParams: FileChooserRequestedParams,
+ inputAction: String,
+ ) {
+ if (Intent(inputAction).resolveActivity(requireActivity().packageManager) == null) {
+ launchFilePicker(filePathCallback, fileChooserParams)
+ return
+ }
+
+ pendingUploadTask = filePathCallback
+ externalCameraLauncher.launch(inputAction)
+ }
+
+ private fun launchImageOrCameraChooser(
+ filePathCallback: ValueCallback>,
+ fileChooserParams: FileChooserRequestedParams,
+ inputAction: String,
+ ) {
+ val cameraString = getString(R.string.imageCaptureCameraGalleryDisambiguationCameraOption)
+ val cameraIcon = com.duckduckgo.mobile.android.R.drawable.ic_camera_24
+
+ val galleryString = getString(R.string.imageCaptureCameraGalleryDisambiguationGalleryOption)
+ val galleryIcon = com.duckduckgo.mobile.android.R.drawable.ic_image_24
+
+ ActionBottomSheetDialog.Builder(requireContext())
+ .setTitle(getString(R.string.imageCaptureCameraGalleryDisambiguationTitle))
+ .setPrimaryItem(galleryString, galleryIcon)
+ .setSecondaryItem(cameraString, cameraIcon)
+ .addEventListener(
+ object : ActionBottomSheetDialog.EventListener() {
+ override fun onPrimaryItemClicked() {
+ launchFilePicker(filePathCallback, fileChooserParams)
+ }
+
+ override fun onSecondaryItemClicked() {
+ launchCameraCapture(filePathCallback, fileChooserParams, inputAction)
+ }
+
+ override fun onBottomSheetDismissed() {
+ filePathCallback.onReceiveValue(null)
+ pendingUploadTask = null
+ }
+ },
+ )
+ .show()
+ }
+
+ private fun acceptsOnly(
+ type: String,
+ acceptTypes: Array,
+ ): Boolean {
+ return acceptTypes.filter { it.startsWith(type) }.size == acceptTypes.size
+ }
+
+ private fun convertAcceptTypesToMimeTypes(acceptTypes: Array): List {
+ val mimeTypeMap = MimeTypeMap.getSingleton()
+ val mimeTypes = mutableSetOf()
+ acceptTypes.forEach { type ->
+ // Attempt to convert any identified file extensions into corresponding MIME types.
+ val fileExtension = MimeTypeMap.getFileExtensionFromUrl(type)
+ if (fileExtension.isNotEmpty()) {
+ mimeTypeMap.getMimeTypeFromExtension(type.substring(1))?.let {
+ mimeTypes.add(it)
+ }
+ } else {
+ mimeTypes.add(type)
+ }
+ }
+ return mimeTypes.toList()
+ }
+
+ fun onBackPressed(): Boolean {
+ if (!isVisible) return false
+
+ if (!simpleWebview.canGoBack()) {
+ exit()
+ return true
+ }
+
+ val history = simpleWebview.copyBackForwardList()
+ if (viewModel.shouldCloseDuckChat(history)) {
+ exit()
+ } else {
+ simpleWebview.goBack()
+ }
+ return true
+ }
+
+ private fun exit() {
+ hideSoftKeyboard()
+ duckChat.closeDuckChat()
+ }
+
+ override fun continueDownload(pendingFileDownload: PendingFileDownload) {
+ fileDownloader.enqueueDownload(pendingFileDownload)
+ }
+
+ override fun cancelDownload() {
+ // NOOP
+ }
+
+ 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) {
+ root.makeSnackbarWithNoBottomInset(getString(command.messageId, command.fileName), DOWNLOAD_SNACKBAR_LENGTH)?.show()
+ }
+
+ private fun downloadFailed(command: DownloadCommand.ShowDownloadFailedMessage) {
+ val downloadFailedSnackbar = root.makeSnackbarWithNoBottomInset(getString(command.messageId), Snackbar.LENGTH_LONG)
+ root.postDelayed({ downloadFailedSnackbar.show() }, DOWNLOAD_SNACKBAR_DELAY)
+ }
+
+ private fun downloadSucceeded(command: DownloadCommand.ShowDownloadSuccessMessage) {
+ val downloadSucceededSnackbar = root.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()
+ }
+ }
+ }
+ root.postDelayed({ downloadSucceededSnackbar.show() }, DOWNLOAD_SNACKBAR_DELAY)
+ }
+
+ 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()
+ }
+ }
+
+ @AnyThread
+ private fun downloadFile() {
+ val pendingDownload = pendingFileDownload ?: return
+
+ pendingFileDownload = null
+
+ continueDownload(pendingDownload)
+ }
+
+ 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)
+ }
+
+ override fun onActivityResult(
+ requestCode: Int,
+ resultCode: Int,
+ data: Intent?,
+ ) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == REQUEST_CODE_CHOOSE_FILE) {
+ handleFileUploadResult(resultCode, data)
+ }
+ }
+
+ private fun handleFileUploadResult(
+ resultCode: Int,
+ intent: Intent?,
+ ) {
+ if (resultCode != RESULT_OK || intent == null) {
+ pendingUploadTask?.onReceiveValue(null)
+ return
+ }
+
+ val uris = fileChooserIntentBuilder.extractSelectedFileUris(intent)
+ pendingUploadTask?.onReceiveValue(uris)
+ }
+
+ override fun onResume() {
+ simpleWebview.onResume()
+ super.onResume()
+ launchDownloadMessagesJob()
+ }
+
+ override fun onPause() {
+ downloadMessagesJob.cancel()
+ simpleWebview.onPause()
+ appCoroutineScope.launch(dispatcherProvider.io()) {
+ cookieManager.flush()
+ }
+ super.onPause()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ appCoroutineScope.launch(dispatcherProvider.io()) {
+ cookieManager.flush()
+ }
+ }
+
+ companion object {
+ 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"
+ 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/fragment_contextual_duck_ai.xml b/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai.xml
new file mode 100644
index 000000000000..c08d56e2d0bd
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..24e3f488335f
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/res/values/widgets.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+