From 36159d6682df14fd66ca89e9e9d88c7b1a07e75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Wed, 10 Dec 2025 10:34:15 +0100 Subject: [PATCH 01/26] Duck.ai: Contextual HP --- .../app/browser/BrowserTabFragment.kt | 22 ++-- .../impl/ui/DuckChatContextualBottomSheet.kt | 77 ++++++++++++ .../bottom_sheet_duck_ai_contextual.xml | 116 ++++++++++++++++++ 3 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatContextualBottomSheet.kt create mode 100644 duckchat/duckchat-impl/src/main/res/layout/bottom_sheet_duck_ai_contextual.xml 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..e98ec52e6856 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.DuckChatContextualBottomSheet import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams import com.duckduckgo.js.messaging.api.JsCallbackData @@ -3105,13 +3106,15 @@ 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) + showDuckChatBottomSheet() + + // 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) } override fun onBackButtonPressed() { @@ -3126,6 +3129,11 @@ class BrowserTabFragment : ) } + private fun showDuckChatBottomSheet() { + val bottomSheet = DuckChatContextualBottomSheet.newInstance() + bottomSheet.show(childFragmentManager, DuckChatContextualBottomSheet.TAG) + } + private fun configureOmnibarTextInput() { omnibar.addTextListener( object : TextListener { 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..71fad436dd9d --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatContextualBottomSheet.kt @@ -0,0 +1,77 @@ +/* + * 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.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import com.duckduckgo.common.utils.extensions.showKeyboard +import com.duckduckgo.duckchat.impl.databinding.BottomSheetDuckAiContextualBinding +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class DuckChatContextualBottomSheet : BottomSheetDialogFragment() { + + private var _binding: BottomSheetDuckAiContextualBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = BottomSheetDuckAiContextualBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + + // Request focus and show the keyboard + binding.inputField.post { + binding.inputField.requestFocus() + showKeyboard(binding.inputField) + } + } + + override fun onStart() { + super.onStart() + dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet -> + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val TAG = "DuckChatBottomSheet" + + // You can use a newInstance pattern if you need to pass arguments + fun newInstance(): DuckChatContextualBottomSheet { + return DuckChatContextualBottomSheet() + } + } +} 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..21040197fbca --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/bottom_sheet_duck_ai_contextual.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + From 0f593a69fda51710e1b078f6f5ac90236ad36f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Wed, 10 Dec 2025 22:38:55 +0100 Subject: [PATCH 02/26] added factory injecting for bottom sheet --- .../app/browser/BrowserTabFragment.kt | 9 +- .../DuckChatContextualBottomSheetFactory.kt | 130 ++++++++++++++++++ .../impl/ui/DuckChatContextualBottomSheet.kt | 71 ++++++++-- .../bottom_sheet_duck_ai_contextual.xml | 10 +- 4 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatContextualBottomSheetFactory.kt 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 e98ec52e6856..febab51b3379 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 @@ -342,7 +343,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -594,6 +594,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 @@ -3130,8 +3133,8 @@ class BrowserTabFragment : } private fun showDuckChatBottomSheet() { - val bottomSheet = DuckChatContextualBottomSheet.newInstance() - bottomSheet.show(childFragmentManager, DuckChatContextualBottomSheet.TAG) + val duckChatContextualSheet = duckChatContextualBottomSheetFactory.create() + duckChatContextualSheet.show(childFragmentManager, DuckChatContextualBottomSheet.TAG) } private fun configureOmnibarTextInput() { 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..9b429930b1d7 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatContextualBottomSheetFactory.kt @@ -0,0 +1,130 @@ +/* + * 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.impl.DuckChatInternal +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.duckchat.impl.ui.filechooser.FileChooserIntentBuilder +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.camera.CameraHardwareChecker +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.navigation.api.GlobalActivityStarter +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 duckChatInternal: DuckChatInternal + + @Inject + lateinit var aiChatDownloadFeature: AIChatDownloadFeature + + @Inject + lateinit var fileChooserIntentBuilder: FileChooserIntentBuilder + + @Inject + lateinit var cameraHardwareChecker: CameraHardwareChecker + + @Inject + lateinit var externalCameraLauncher: UploadFromExternalMediaAppLauncher + + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + 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, + duckChat = duckChatInternal, + aiChatDownloadFeature = aiChatDownloadFeature, + fileChooserIntentBuilder = fileChooserIntentBuilder, + cameraHardwareChecker = cameraHardwareChecker, + externalCameraLauncher = externalCameraLauncher, + globalActivityStarter = globalActivityStarter, + ) + } +} 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 index 71fad436dd9d..04de5d15e94e 100644 --- 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 @@ -20,14 +20,49 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.WindowManager import android.widget.FrameLayout -import com.duckduckgo.common.utils.extensions.showKeyboard +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.downloads.api.DownloadStateListener +import com.duckduckgo.downloads.api.DownloadsFileActions +import com.duckduckgo.downloads.api.FileDownloader +import com.duckduckgo.duckchat.impl.DuckChatInternal 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.ui.filechooser.FileChooserIntentBuilder +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.camera.CameraHardwareChecker +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.navigation.api.GlobalActivityStarter import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.MaterialShapeDrawable +import kotlinx.coroutines.CoroutineScope -class DuckChatContextualBottomSheet : BottomSheetDialogFragment() { +class DuckChatContextualBottomSheet( + viewModelFactory: FragmentViewModelFactory, + webViewClient: DuckChatWebViewClient, + contentScopeScripts: JsMessaging, + duckChatJSHelper: DuckChatJSHelper, + subscriptionsHandler: SubscriptionsHandler, + appCoroutineScope: CoroutineScope, + dispatcherProvider: DispatcherProvider, + browserNav: BrowserNav, + appBuildConfig: AppBuildConfig, + fileDownloader: FileDownloader, + downloadCallback: DownloadStateListener, + downloadsFileActions: DownloadsFileActions, + duckChat: DuckChatInternal, + aiChatDownloadFeature: AIChatDownloadFeature, + fileChooserIntentBuilder: FileChooserIntentBuilder, + cameraHardwareChecker: CameraHardwareChecker, + externalCameraLauncher: UploadFromExternalMediaAppLauncher, + globalActivityStarter: GlobalActivityStarter, +) : BottomSheetDialogFragment() { private var _binding: BottomSheetDuckAiContextualBinding? = null private val binding get() = _binding!! @@ -44,12 +79,11 @@ class DuckChatContextualBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) - - // Request focus and show the keyboard - binding.inputField.post { - binding.inputField.requestFocus() - showKeyboard(binding.inputField) + binding.actionSend.setOnClickListener { + dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet -> + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } } } @@ -58,6 +92,20 @@ class DuckChatContextualBottomSheet : BottomSheetDialogFragment() { dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet -> val behavior = BottomSheetBehavior.from(bottomSheet) behavior.state = BottomSheetBehavior.STATE_COLLAPSED + + val shapeDrawable = MaterialShapeDrawable.createWithElevationOverlay(context) + shapeDrawable.shapeAppearanceModel = shapeDrawable.shapeAppearanceModel + .toBuilder() + .setTopLeftCorner( + CornerFamily.ROUNDED, + requireContext().resources.getDimension(com.duckduckgo.mobile.android.R.dimen.dialogBorderRadius), + ) + .setTopRightCorner( + CornerFamily.ROUNDED, + requireContext().resources.getDimension(com.duckduckgo.mobile.android.R.dimen.dialogBorderRadius), + ) + .build() + bottomSheet.background = shapeDrawable } } @@ -68,10 +116,5 @@ class DuckChatContextualBottomSheet : BottomSheetDialogFragment() { companion object { const val TAG = "DuckChatBottomSheet" - - // You can use a newInstance pattern if you need to pass arguments - fun newInstance(): DuckChatContextualBottomSheet { - return DuckChatContextualBottomSheet() - } } } 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 index 21040197fbca..a93845fe4402 100644 --- 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 @@ -20,7 +20,7 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/inputModeWidgetLayout" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="match_parent" android:padding="@dimen/keyline_4"> + From 351b25bfeee7f4fc99ff578296ec8cb3e85584ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Fri, 12 Dec 2025 15:45:03 +0100 Subject: [PATCH 03/26] adding simple UI --- .../main/res/values/design-system-theming.xml | 2 +- .../impl/ui/DuckChatContextualBottomSheet.kt | 389 +++++++++++++++--- .../impl/ui/DuckChatWebViewFragment.kt | 2 +- .../bottom_sheet_duck_ai_contextual.xml | 38 +- .../src/main/res/values/widgets.xml | 30 ++ versions.properties | 2 +- 6 files changed, 394 insertions(+), 69 deletions(-) create mode 100644 duckchat/duckchat-impl/src/main/res/values/widgets.xml diff --git a/android-design-system/design-system/src/main/res/values/design-system-theming.xml b/android-design-system/design-system/src/main/res/values/design-system-theming.xml index 69b86eaf41c3..cdbf4940d432 100644 --- a/android-design-system/design-system/src/main/res/values/design-system-theming.xml +++ b/android-design-system/design-system/src/main/res/values/design-system-theming.xml @@ -51,7 +51,7 @@ @style/Widget.DuckDuckGo.PopUpOverflowMenu @style/Widget.DuckDuckGo.OverflowButton @style/Widget.DuckDuckGo.PopupMenu - @style/Widget.DuckDuckGo.BottomSheetDialog + @style/Widget.DuckDuckGo.TabLayout @style/Widget.DuckDuckGo.RadioButton @style/Widget.DuckDuckGo.CheckBox 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 index 04de5d15e94e..bc469c8a98c8 100644 --- 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 @@ -16,105 +16,392 @@ package com.duckduckgo.duckchat.impl.ui +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.os.Message +import android.provider.MediaStore import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout +import android.webkit.MimeTypeMap +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebChromeClient.FileChooserParams +import android.webkit.WebSettings +import android.webkit.WebView +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.dialog.ActionBottomSheetDialog +import com.duckduckgo.common.ui.view.gone import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.FragmentViewModelFactory import com.duckduckgo.downloads.api.DownloadStateListener import com.duckduckgo.downloads.api.DownloadsFileActions import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.duckchat.impl.DuckChatInternal +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.duckchat.impl.ui.DuckChatWebViewFragment.Companion.REQUEST_CODE_CHOOSE_FILE +import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment.FileChooserRequestedParams import com.duckduckgo.duckchat.impl.ui.filechooser.FileChooserIntentBuilder import com.duckduckgo.duckchat.impl.ui.filechooser.capture.camera.CameraHardwareChecker import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.CouldNotCapturePermissionDenied +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.ErrorAccessingMediaApp +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.MediaCaptured +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.NoMediaCaptured +import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.js.messaging.api.SubscriptionEventData import com.duckduckgo.navigation.api.GlobalActivityStarter +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.shape.CornerFamily -import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.CoroutineScope +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 class DuckChatContextualBottomSheet( - viewModelFactory: FragmentViewModelFactory, - webViewClient: DuckChatWebViewClient, - contentScopeScripts: JsMessaging, - duckChatJSHelper: DuckChatJSHelper, - subscriptionsHandler: SubscriptionsHandler, - appCoroutineScope: CoroutineScope, - dispatcherProvider: DispatcherProvider, - browserNav: BrowserNav, - appBuildConfig: AppBuildConfig, - fileDownloader: FileDownloader, - downloadCallback: DownloadStateListener, - downloadsFileActions: DownloadsFileActions, - duckChat: DuckChatInternal, - aiChatDownloadFeature: AIChatDownloadFeature, - fileChooserIntentBuilder: FileChooserIntentBuilder, - cameraHardwareChecker: CameraHardwareChecker, - externalCameraLauncher: UploadFromExternalMediaAppLauncher, - globalActivityStarter: GlobalActivityStarter, + 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 duckChat: DuckChatInternal, + private val aiChatDownloadFeature: AIChatDownloadFeature, + private val fileChooserIntentBuilder: FileChooserIntentBuilder, + private val cameraHardwareChecker: CameraHardwareChecker, + private val externalCameraLauncher: UploadFromExternalMediaAppLauncher, + private val globalActivityStarter: GlobalActivityStarter, ) : BottomSheetDialogFragment() { - private var _binding: BottomSheetDuckAiContextualBinding? = null - private val binding get() = _binding!! + internal lateinit var simpleWebview: WebView + internal lateinit var inputControls: View + + private val viewModel: DuckChatWebViewViewModel by lazy { + ViewModelProvider(this, viewModelFactory)[DuckChatWebViewViewModel::class.java] + } + + private var pendingUploadTask: ValueCallback>? = null + + override fun getTheme(): Int { + return R.style.DuckChatBottomSheetDialogTheme + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { - _binding = BottomSheetDuckAiContextualBinding.inflate(inflater, container, false) + val binding = BottomSheetDuckAiContextualBinding.inflate(inflater, container, false) + simpleWebview = binding.simpleWebview // Initialize simpleWebview from the binding + inputControls = binding.inputModeWidgetCard // Initialize simpleWebview from the binding + configureViews(binding) return binding.root } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + private fun configureViews(binding: BottomSheetDuckAiContextualBinding) { + (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_COLLAPSED + configureDialogButtons(binding) + } + private fun configureDialogButtons(binding: BottomSheetDuckAiContextualBinding) { binding.actionSend.setOnClickListener { - dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet -> - val behavior = BottomSheetBehavior.from(bottomSheet) - behavior.state = BottomSheetBehavior.STATE_EXPANDED + inputControls.gone() + (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + + 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()) { + 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(simpleWebview, it.messageId, BaseTransientBottomBar.LENGTH_SHORT).show() + } } + pendingUploadTask = null } + + observeViewModel() + } + + 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) } - override fun onStart() { - super.onStart() - dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet -> - val behavior = BottomSheetBehavior.from(bottomSheet) - behavior.state = BottomSheetBehavior.STATE_COLLAPSED - - val shapeDrawable = MaterialShapeDrawable.createWithElevationOverlay(context) - shapeDrawable.shapeAppearanceModel = shapeDrawable.shapeAppearanceModel - .toBuilder() - .setTopLeftCorner( - CornerFamily.ROUNDED, - requireContext().resources.getDimension(com.duckduckgo.mobile.android.R.dimen.dialogBorderRadius), - ) - .setTopRightCorner( - CornerFamily.ROUNDED, - requireContext().resources.getDimension(com.duckduckgo.mobile.android.R.dimen.dialogBorderRadius), - ) - .build() - bottomSheet.background = shapeDrawable + 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) } } - override fun onDestroyView() { - super.onDestroyView() - _binding = null + private fun acceptsOnly( + type: String, + acceptTypes: Array, + ): Boolean { + return acceptTypes.filter { it.startsWith(type) }.size == acceptTypes.size + } + + 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 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() + } + + 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 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) } 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" } } 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 index a93845fe4402..e9dac3dd835d 100644 --- 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 @@ -14,26 +14,29 @@ ~ limitations under the License. --> - + android:layout_height="match_parent"> + + + android:layout_width="match_parent" + android:layout_height="150dp" + android:layout_margin="@dimen/keyline_4"> - + android:minHeight="800dp"> + + + + + + - + 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..ecc4cd71d66a --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/values/widgets.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/versions.properties b/versions.properties index 1b84febfe910..3123be50e575 100644 --- a/versions.properties +++ b/versions.properties @@ -106,7 +106,7 @@ version.com.slack.lint.compose..compose-lint-checks=1.4.2 version.google.android.flexbox=3.0.0 -version.google.android.material=1.12.0 +version.google.android.material=1.13.0 version.google.dagger=2.51.1 From c66778b6eb7a2713dfd64b4289241d248fb4f525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Mon, 15 Dec 2025 11:58:20 +0100 Subject: [PATCH 04/26] remove camera capabilities --- .../DuckChatContextualBottomSheetFactory.kt | 24 -- .../impl/ui/DuckChatContextualBottomSheet.kt | 246 +++++++----------- 2 files changed, 92 insertions(+), 178 deletions(-) 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 index 9b429930b1d7..e6a4356b06bf 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatContextualBottomSheetFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatContextualBottomSheetFactory.kt @@ -25,17 +25,12 @@ 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.impl.DuckChatInternal 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.duckchat.impl.ui.filechooser.FileChooserIntentBuilder -import com.duckduckgo.duckchat.impl.ui.filechooser.capture.camera.CameraHardwareChecker -import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher import com.duckduckgo.js.messaging.api.JsMessaging -import com.duckduckgo.navigation.api.GlobalActivityStarter import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import kotlinx.coroutines.CoroutineScope @@ -88,23 +83,9 @@ class DuckChatContextualBottomSheetFactoryImpl @Inject constructor() : DuckChatC @Inject lateinit var downloadsFileActions: DownloadsFileActions - @Inject - lateinit var duckChatInternal: DuckChatInternal - @Inject lateinit var aiChatDownloadFeature: AIChatDownloadFeature - @Inject - lateinit var fileChooserIntentBuilder: FileChooserIntentBuilder - - @Inject - lateinit var cameraHardwareChecker: CameraHardwareChecker - - @Inject - lateinit var externalCameraLauncher: UploadFromExternalMediaAppLauncher - - @Inject - lateinit var globalActivityStarter: GlobalActivityStarter override fun create(): DuckChatContextualBottomSheet { return DuckChatContextualBottomSheet( viewModelFactory = viewModelFactory, @@ -119,12 +100,7 @@ class DuckChatContextualBottomSheetFactoryImpl @Inject constructor() : DuckChatC fileDownloader = fileDownloader, downloadCallback = downloadCallback, downloadsFileActions = downloadsFileActions, - duckChat = duckChatInternal, aiChatDownloadFeature = aiChatDownloadFeature, - fileChooserIntentBuilder = fileChooserIntentBuilder, - cameraHardwareChecker = cameraHardwareChecker, - externalCameraLauncher = externalCameraLauncher, - globalActivityStarter = globalActivityStarter, ) } } 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 index bc469c8a98c8..47b9fcde77bf 100644 --- 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 @@ -16,64 +16,61 @@ package com.duckduckgo.duckchat.impl.ui -import android.content.Intent -import android.net.Uri +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager import android.os.Bundle +import android.os.Environment import android.os.Message -import android.provider.MediaStore import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.webkit.MimeTypeMap -import android.webkit.ValueCallback import android.webkit.WebChromeClient -import android.webkit.WebChromeClient.FileChooserParams import android.webkit.WebSettings import android.webkit.WebView +import androidx.annotation.AnyThread +import androidx.core.content.ContextCompat 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.dialog.ActionBottomSheetDialog import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.makeSnackbarWithNoBottomInset +import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.FragmentViewModelFactory +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.duckchat.impl.DuckChatInternal +import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload 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.duckchat.impl.ui.DuckChatWebViewFragment.Companion.REQUEST_CODE_CHOOSE_FILE -import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment.FileChooserRequestedParams -import com.duckduckgo.duckchat.impl.ui.filechooser.FileChooserIntentBuilder -import com.duckduckgo.duckchat.impl.ui.filechooser.capture.camera.CameraHardwareChecker -import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher -import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.CouldNotCapturePermissionDenied -import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.ErrorAccessingMediaApp -import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.MediaCaptured -import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.NoMediaCaptured +import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment.Companion.PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.js.messaging.api.SubscriptionEventData -import com.duckduckgo.navigation.api.GlobalActivityStarter 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.BaseTransientBottomBar 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, @@ -88,13 +85,8 @@ class DuckChatContextualBottomSheet( private val fileDownloader: FileDownloader, private val downloadCallback: DownloadStateListener, private val downloadsFileActions: DownloadsFileActions, - private val duckChat: DuckChatInternal, private val aiChatDownloadFeature: AIChatDownloadFeature, - private val fileChooserIntentBuilder: FileChooserIntentBuilder, - private val cameraHardwareChecker: CameraHardwareChecker, - private val externalCameraLauncher: UploadFromExternalMediaAppLauncher, - private val globalActivityStarter: GlobalActivityStarter, -) : BottomSheetDialogFragment() { +) : BottomSheetDialogFragment(), DownloadConfirmationDialogListener { internal lateinit var simpleWebview: WebView internal lateinit var inputControls: View @@ -103,7 +95,8 @@ class DuckChatContextualBottomSheet( ViewModelProvider(this, viewModelFactory)[DuckChatWebViewViewModel::class.java] } - private var pendingUploadTask: ValueCallback>? = null + private var pendingFileDownload: PendingFileDownload? = null + private val downloadMessagesJob = ConflatedJob() override fun getTheme(): Int { return R.style.DuckChatBottomSheetDialogTheme @@ -162,21 +155,6 @@ class DuckChatContextualBottomSheet( } 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 { @@ -196,7 +174,7 @@ class DuckChatContextualBottomSheet( it.setDownloadListener { url, _, contentDisposition, mimeType, _ -> appCoroutineScope.launch(dispatcherProvider.io()) { if (aiChatDownloadFeature.self().isEnabled()) { - // requestFileDownload(url, contentDisposition, mimeType) + requestFileDownload(url, contentDisposition, mimeType) } } } @@ -245,24 +223,8 @@ class DuckChatContextualBottomSheet( 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(simpleWebview, it.messageId, BaseTransientBottomBar.LENGTH_SHORT).show() - } - } - pendingUploadTask = null - } - observeViewModel() + launchDownloadMessagesJob() } private fun observeViewModel() { @@ -281,127 +243,103 @@ class DuckChatContextualBottomSheet( }.launchIn(lifecycleScope) } - fun showFileChooser( - filePathCallback: ValueCallback>, - fileChooserParams: FileChooserParams, + private fun requestFileDownload( + url: String, + contentDisposition: String?, + mimeType: String, ) { - 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) + pendingFileDownload = PendingFileDownload( + url = url, + contentDisposition = contentDisposition, + mimeType = mimeType, + subfolder = Environment.DIRECTORY_DOWNLOADS, + fileName = "duck.ai_${System.currentTimeMillis()}", + ) + + if (hasWriteStoragePermission()) { + downloadFile() + } else { + requestWriteStoragePermission() + } + } - acceptsOnly("video/", fileChooserParams.acceptTypes) && cameraHardwareAvailable -> - launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE) + private fun minSdk30(): Boolean { + return appBuildConfig.sdkInt >= 30 + } - acceptsOnly("audio/", fileChooserParams.acceptTypes) -> - launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.Audio.Media.RECORD_SOUND_ACTION) + @Suppress("NewApi") + private fun hasWriteStoragePermission(): Boolean { + return minSdk30() || + ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + } - else -> - launchFilePicker(filePathCallback, fileChooserRequestedParams) - } - } + private fun requestWriteStoragePermission() { + requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE) + } - fileChooserParams.acceptTypes.any { it.startsWith("image/") && cameraHardwareAvailable } -> - launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_IMAGE_CAPTURE) + @AnyThread + private fun downloadFile() { + val pendingDownload = pendingFileDownload ?: return - fileChooserParams.acceptTypes.any { it.startsWith("video/") && cameraHardwareAvailable } -> - launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE) + pendingFileDownload = null - else -> - launchFilePicker(filePathCallback, fileChooserRequestedParams) - } + continueDownload(pendingDownload) } - private fun acceptsOnly( - type: String, - acceptTypes: Array, - ): Boolean { - return acceptTypes.filter { it.startsWith(type) }.size == acceptTypes.size + override fun continueDownload(pendingFileDownload: PendingFileDownload) { + fileDownloader.enqueueDownload(pendingFileDownload) } - 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() + override fun cancelDownload() { + // NOOP } - 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) + private fun launchDownloadMessagesJob() { + downloadMessagesJob += lifecycleScope.launch { + downloadCallback.commands().cancellable().collect { + processFileDownloadedCommand(it) } } - return mimeTypes.toList() } - private fun launchCameraCapture( - filePathCallback: ValueCallback>, - fileChooserParams: FileChooserRequestedParams, - inputAction: String, - ) { - if (Intent(inputAction).resolveActivity(requireActivity().packageManager) == null) { - launchFilePicker(filePathCallback, fileChooserParams) - return + private fun processFileDownloadedCommand(command: DownloadCommand) { + when (command) { + is DownloadCommand.ShowDownloadStartedMessage -> downloadStarted(command) + is DownloadCommand.ShowDownloadFailedMessage -> downloadFailed(command) + is DownloadCommand.ShowDownloadSuccessMessage -> downloadSucceeded(command) } + } - pendingUploadTask = filePathCallback - externalCameraLauncher.launch(inputAction) + @SuppressLint("WrongConstant") + private fun downloadStarted(command: DownloadCommand.ShowDownloadStartedMessage) { + simpleWebview.makeSnackbarWithNoBottomInset(getString(command.messageId, command.fileName), DOWNLOAD_SNACKBAR_LENGTH)?.show() } - 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 downloadFailed(command: DownloadCommand.ShowDownloadFailedMessage) { + val downloadFailedSnackbar = simpleWebview.makeSnackbarWithNoBottomInset(getString(command.messageId), Snackbar.LENGTH_LONG) + simpleWebview.postDelayed({ downloadFailedSnackbar.show() }, DOWNLOAD_SNACKBAR_DELAY) + } + + private fun downloadSucceeded(command: DownloadCommand.ShowDownloadSuccessMessage) { + val downloadSucceededSnackbar = 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() + } + } + } + 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 } } From f40b675e0b362d3b24945cef124b41282f63cf89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Mon, 15 Dec 2025 16:40:56 +0100 Subject: [PATCH 05/26] first version of contextual sheet --- .../drawable/duck_ai_prompt_background.xml | 20 +++ .../res/drawable/ic_arrow_down_right_16.xml | 25 ++++ .../main/res/drawable/ic_duck_ai_color_24.xml | 46 ++++++ .../src/main/res/drawable/ic_expand_24.xml | 28 ++++ .../impl/ui/DuckChatContextualBottomSheet.kt | 17 ++- .../bottom_sheet_duck_ai_contextual.xml | 137 +++++++++++++++--- .../src/main/res/values/donottranslate.xml | 24 +++ 7 files changed, 270 insertions(+), 27 deletions(-) create mode 100644 android-design-system/design-system/src/main/res/drawable/duck_ai_prompt_background.xml create mode 100644 android-design-system/design-system/src/main/res/drawable/ic_arrow_down_right_16.xml create mode 100644 android-design-system/design-system/src/main/res/drawable/ic_duck_ai_color_24.xml create mode 100644 android-design-system/design-system/src/main/res/drawable/ic_expand_24.xml create mode 100644 duckchat/duckchat-impl/src/main/res/values/donottranslate.xml 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/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 index 47b9fcde77bf..02cb6d6de4ae 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -53,7 +54,6 @@ 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.duckchat.impl.ui.DuckChatWebViewFragment.Companion.PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.js.messaging.api.SubscriptionEventData @@ -102,6 +102,14 @@ class DuckChatContextualBottomSheet( return R.style.DuckChatBottomSheetDialogTheme } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog + // Ensure BottomSheet is draggable so the drag handle appears + dialog.behavior.isDraggable = true + dialog.behavior.state = BottomSheetBehavior.STATE_COLLAPSED + return dialog + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -110,13 +118,8 @@ class DuckChatContextualBottomSheet( val binding = BottomSheetDuckAiContextualBinding.inflate(inflater, container, false) simpleWebview = binding.simpleWebview // Initialize simpleWebview from the binding inputControls = binding.inputModeWidgetCard // Initialize simpleWebview from the binding - configureViews(binding) - return binding.root - } - - private fun configureViews(binding: BottomSheetDuckAiContextualBinding) { - (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_COLLAPSED configureDialogButtons(binding) + return binding.root } private fun configureDialogButtons(binding: BottomSheetDuckAiContextualBinding) { 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 index e9dac3dd835d..c7ab4c19bed0 100644 --- 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 @@ -14,33 +14,131 @@ ~ limitations under the License. --> - + android:layout_height="match_parent" + android:orientation="vertical"> - + android:layout_height="wrap_content" + android:orientation="horizontal"> + + + + + + + + + + + + + + + + + + + + + android:layout_marginHorizontal="@dimen/keyline_4" + android:layout_marginTop="@dimen/keyline_2" + android:layout_marginBottom="@dimen/keyline_4" + app:cardElevation="0dp" + app:strokeColor="?attr/daxColorAccentBlue" + app:strokeWidth="@dimen/omnibarOutlineWidth"> @@ -73,8 +171,6 @@ + android:visibility="gone" + app:srcCompat="@drawable/ic_microphone_24" + tools:visibility="visible" /> + android:visibility="gone" + app:srcCompat="@drawable/ic_close_circle_small_secondary_24" /> + android:layout_height="800dp" /> - 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 From f88a379c6bbabcebe9ad1a2d6f262c09c328f4a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Mon, 15 Dec 2025 16:59:38 +0100 Subject: [PATCH 06/26] added skeletons prompts --- .../layout/bottom_sheet_duck_ai_contextual.xml | 15 +++++++++------ .../duckchat-impl/src/main/res/values/widgets.xml | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) 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 index c7ab4c19bed0..f2720f7671ab 100644 --- 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 @@ -26,13 +26,15 @@ android:id="@+id/contextualModeButtons" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="horizontal"> + android:orientation="horizontal" + android:paddingTop="@dimen/keyline_4" + android:paddingHorizontal="@dimen/keyline_2"> - @@ -56,7 +58,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/contextualDragHandle" - app:layout_constraintBottom_toBottomOf="parent" + android:layout_marginTop="@dimen/keyline_4" android:gravity="center" app:typography="h2" android:drawableStart="@drawable/ic_duck_ai_color_24" @@ -83,6 +85,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" + android:layout_marginTop="@dimen/keyline_4" android:paddingHorizontal="@dimen/keyline_4" android:paddingTop="@dimen/keyline_4" android:paddingBottom="@dimen/keyline_3"> diff --git a/duckchat/duckchat-impl/src/main/res/values/widgets.xml b/duckchat/duckchat-impl/src/main/res/values/widgets.xml index ecc4cd71d66a..57f63d004f09 100644 --- a/duckchat/duckchat-impl/src/main/res/values/widgets.xml +++ b/duckchat/duckchat-impl/src/main/res/values/widgets.xml @@ -24,7 +24,7 @@ false true true - 180dp + 400dp From 4f858eebe9a708cdc78b91303d72ec527a646a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Mon, 15 Dec 2025 18:51:40 +0100 Subject: [PATCH 07/26] remove extra prompts --- .../bottom_sheet_duck_ai_contextual.xml | 24 ------------------- .../src/main/res/values/widgets.xml | 2 +- 2 files changed, 1 insertion(+), 25 deletions(-) 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 index f2720f7671ab..1a7e66df37b1 100644 --- 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 @@ -101,30 +101,6 @@ android:drawablePadding="@dimen/keyline_2" android:text="@string/duckAIContextualPromptSummarize"/> - - - - false true true - 400dp + 310dp From d662dcd51273c84885f5e6b7b857f4ba64bf04b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Mon, 15 Dec 2025 18:53:35 +0100 Subject: [PATCH 08/26] proper peak height --- duckchat/duckchat-impl/src/main/res/values/widgets.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/res/values/widgets.xml b/duckchat/duckchat-impl/src/main/res/values/widgets.xml index 040dbb36e416..7e22cb928327 100644 --- a/duckchat/duckchat-impl/src/main/res/values/widgets.xml +++ b/duckchat/duckchat-impl/src/main/res/values/widgets.xml @@ -24,7 +24,7 @@ false true true - 310dp + 314dp From ba79b82e64345fa6423d52dce7a7d0c9e268919f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Mon, 15 Dec 2025 22:29:07 +0100 Subject: [PATCH 09/26] more ui cleanup --- .../impl/ui/DuckChatContextualBottomSheet.kt | 36 +++++++++++-------- .../bottom_sheet_duck_ai_contextual.xml | 10 +++--- .../src/main/res/values/widgets.xml | 4 +-- 3 files changed, 30 insertions(+), 20 deletions(-) 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 index 02cb6d6de4ae..7c122b3c2193 100644 --- 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 @@ -18,7 +18,6 @@ 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 @@ -90,6 +89,7 @@ class DuckChatContextualBottomSheet( internal lateinit var simpleWebview: WebView internal lateinit var inputControls: View + internal lateinit var promptsList: View private val viewModel: DuckChatWebViewViewModel by lazy { ViewModelProvider(this, viewModelFactory)[DuckChatWebViewViewModel::class.java] @@ -102,31 +102,39 @@ class DuckChatContextualBottomSheet( return R.style.DuckChatBottomSheetDialogTheme } - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog - // Ensure BottomSheet is draggable so the drag handle appears - dialog.behavior.isDraggable = true - dialog.behavior.state = BottomSheetBehavior.STATE_COLLAPSED - return dialog - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val binding = BottomSheetDuckAiContextualBinding.inflate(inflater, container, false) - simpleWebview = binding.simpleWebview // Initialize simpleWebview from the binding - inputControls = binding.inputModeWidgetCard // Initialize simpleWebview from the binding - configureDialogButtons(binding) + simpleWebview = binding.simpleWebview + inputControls = binding.inputModeWidgetCard + promptsList = binding.contextualModePrompts + configureViews(binding) return binding.root } + private fun configureViews(binding: BottomSheetDuckAiContextualBinding) { + (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_COLLAPSED + configureDialogButtons(binding) + } + private fun configureDialogButtons(binding: BottomSheetDuckAiContextualBinding) { + binding.contextualClose.setOnClickListener { + dismiss() + } binding.actionSend.setOnClickListener { - inputControls.gone() - (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED + expandSheet() } + binding.inputField.onFocusChangeListener + } + + private fun expandSheet() { + inputControls.gone() + promptsList.gone() + (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED + (dialog as BottomSheetDialog).behavior.isDraggable = true } override fun onViewCreated( 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 index 1a7e66df37b1..ea07f90251de 100644 --- 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 @@ -28,6 +28,7 @@ android:layout_height="wrap_content" android:orientation="horizontal" android:paddingTop="@dimen/keyline_4" + android:layout_marginBottom="@dimen/keyline_4" android:paddingHorizontal="@dimen/keyline_2"> @@ -107,7 +107,7 @@ android:id="@+id/inputModeWidgetCard" style="@style/Widget.DuckDuckGo.OmnibarCardView" android:layout_width="match_parent" - android:layout_height="150dp" + android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/keyline_4" android:layout_marginTop="@dimen/keyline_2" android:layout_marginBottom="@dimen/keyline_4" @@ -150,14 +150,15 @@ @@ -186,6 +187,7 @@ android:importantForAccessibility="no" android:scaleType="centerInside" android:src="@drawable/ic_arrow_up_24" + android:layout_margin="@dimen/keyline_1" app:tint="?attr/daxColorWhite" /> diff --git a/duckchat/duckchat-impl/src/main/res/values/widgets.xml b/duckchat/duckchat-impl/src/main/res/values/widgets.xml index 7e22cb928327..7787166cbf52 100644 --- a/duckchat/duckchat-impl/src/main/res/values/widgets.xml +++ b/duckchat/duckchat-impl/src/main/res/values/widgets.xml @@ -23,8 +23,8 @@ From 3eb18e5463625ae85722c73b0f32cd89260ec617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Mon, 15 Dec 2025 22:54:32 +0100 Subject: [PATCH 10/26] first interaction with drag and close --- .../impl/ui/DuckChatContextualBottomSheet.kt | 47 +++++++++++++++++-- .../bottom_sheet_duck_ai_contextual.xml | 3 +- 2 files changed, 45 insertions(+), 5 deletions(-) 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 index 7c122b3c2193..c350a156790e 100644 --- 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 @@ -97,6 +97,7 @@ class DuckChatContextualBottomSheet( private var pendingFileDownload: PendingFileDownload? = null private val downloadMessagesJob = ConflatedJob() + private var isExpanded = false override fun getTheme(): Int { return R.style.DuckChatBottomSheetDialogTheme @@ -116,7 +117,39 @@ class DuckChatContextualBottomSheet( } private fun configureViews(binding: BottomSheetDuckAiContextualBinding) { - (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_COLLAPSED + val bottomSheetDialog = dialog as? BottomSheetDialog + bottomSheetDialog?.let { + it.behavior.state = BottomSheetBehavior.STATE_COLLAPSED + // Set up callback to prevent collapsing after expansion + it.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + // Prevent collapsing after expansion - only allow expanded or hidden/dismissed states + if (isExpanded && newState == BottomSheetBehavior.STATE_COLLAPSED) { + // If already expanded, prevent collapsing - force back to expanded + // Use post to avoid infinite loop + bottomSheet.post { + it.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + isExpanded = true + // Set skipCollapsed to prevent dragging back to collapsed state + it.behavior.skipCollapsed = true + it.behavior.isDraggable = true + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + // Prevent sliding to collapsed state if already expanded + if (isExpanded && slideOffset < 0.5f) { + // If trying to collapse, keep it expanded + bottomSheet.post { + it.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + } + }) + } configureDialogButtons(binding) } @@ -127,14 +160,20 @@ class DuckChatContextualBottomSheet( binding.actionSend.setOnClickListener { expandSheet() } - binding.inputField.onFocusChangeListener + binding.inputField.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + expandSheet() + } + } } private fun expandSheet() { inputControls.gone() promptsList.gone() - (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED - (dialog as BottomSheetDialog).behavior.isDraggable = true + val bottomSheetDialog = dialog as? BottomSheetDialog + bottomSheetDialog?.let { + it.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } } override fun onViewCreated( 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 index ea07f90251de..5e8e1dfc9923 100644 --- 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 @@ -202,7 +202,8 @@ + android:layout_height="800dp" + android:layout_marginBottom="@dimen/keyline_4"/> From fae0e8b9c2dddac08888bb13e9b3046458389c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Tue, 16 Dec 2025 20:37:01 +0100 Subject: [PATCH 11/26] clean up sheet --- .../app/browser/BrowserTabFragment.kt | 19 ++++----- .../impl/ui/DuckChatContextualBottomSheet.kt | 20 --------- .../bottom_sheet_duck_ai_contextual.xml | 41 ++++++++++++++++--- .../src/main/res/values/widgets.xml | 4 +- 4 files changed, 47 insertions(+), 37 deletions(-) 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 febab51b3379..100246f6bc24 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -277,7 +277,6 @@ import com.duckduckgo.common.utils.extensions.hideKeyboard import com.duckduckgo.common.utils.extensions.html import com.duckduckgo.common.utils.extensions.showKeyboard import com.duckduckgo.common.utils.extensions.websiteFromGeoLocationsApiOrigin -import com.duckduckgo.common.utils.keyboardVisibilityFlow import com.duckduckgo.common.utils.playstore.PlayStoreUtils import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.FragmentScope @@ -4993,15 +4992,15 @@ class BrowserTabFragment : } private fun configureBrowserTabKeyboardListener() { - binding.root.rootView.keyboardVisibilityFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) - .distinctUntilChanged() - .onEach { isVisible -> - if (isVisible) { - viewModel.sendKeyboardFocusedPixel() - } - } - .launchIn(lifecycleScope) + // binding.root.rootView.keyboardVisibilityFlow() + // .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + // .distinctUntilChanged() + // .onEach { isVisible -> + // if (isVisible) { + // viewModel.sendKeyboardFocusedPixel() + // } + // } + // .launchIn(lifecycleScope) } } 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 index c350a156790e..1977ccc865bc 100644 --- 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 @@ -123,14 +123,6 @@ class DuckChatContextualBottomSheet( // Set up callback to prevent collapsing after expansion it.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { - // Prevent collapsing after expansion - only allow expanded or hidden/dismissed states - if (isExpanded && newState == BottomSheetBehavior.STATE_COLLAPSED) { - // If already expanded, prevent collapsing - force back to expanded - // Use post to avoid infinite loop - bottomSheet.post { - it.behavior.state = BottomSheetBehavior.STATE_EXPANDED - } - } if (newState == BottomSheetBehavior.STATE_EXPANDED) { isExpanded = true // Set skipCollapsed to prevent dragging back to collapsed state @@ -140,13 +132,6 @@ class DuckChatContextualBottomSheet( } override fun onSlide(bottomSheet: View, slideOffset: Float) { - // Prevent sliding to collapsed state if already expanded - if (isExpanded && slideOffset < 0.5f) { - // If trying to collapse, keep it expanded - bottomSheet.post { - it.behavior.state = BottomSheetBehavior.STATE_EXPANDED - } - } } }) } @@ -160,11 +145,6 @@ class DuckChatContextualBottomSheet( binding.actionSend.setOnClickListener { expandSheet() } - binding.inputField.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - expandSheet() - } - } } private fun expandSheet() { 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 index 5e8e1dfc9923..4c647330311b 100644 --- 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 @@ -14,19 +14,21 @@ ~ limitations under the License. --> - + android:layout_height="wrap_content"> @@ -81,10 +83,21 @@ + + + + - + diff --git a/duckchat/duckchat-impl/src/main/res/values/widgets.xml b/duckchat/duckchat-impl/src/main/res/values/widgets.xml index 7787166cbf52..82c704ddc634 100644 --- a/duckchat/duckchat-impl/src/main/res/values/widgets.xml +++ b/duckchat/duckchat-impl/src/main/res/values/widgets.xml @@ -23,8 +23,8 @@ From 9f9bba9311ab16d284da3eb5cf1e3cc558d474aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Tue, 16 Dec 2025 21:25:31 +0100 Subject: [PATCH 12/26] calculate peek height programatically --- .../impl/ui/DuckChatContextualBottomSheet.kt | 27 +++++++++++++++++++ .../bottom_sheet_duck_ai_contextual.xml | 21 ++++++++++----- .../src/main/res/values/widgets.xml | 1 - 3 files changed, 42 insertions(+), 7 deletions(-) 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 index 1977ccc865bc..5ee500615d98 100644 --- 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 @@ -30,6 +30,7 @@ import android.webkit.WebSettings import android.webkit.WebView import androidx.annotation.AnyThread import androidx.core.content.ContextCompat +import androidx.core.view.doOnLayout import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.duckduckgo.app.tabs.BrowserNav @@ -90,6 +91,7 @@ class DuckChatContextualBottomSheet( internal lateinit var simpleWebview: WebView internal lateinit var inputControls: View internal lateinit var promptsList: View + internal lateinit var webViewContainer: View private val viewModel: DuckChatWebViewViewModel by lazy { ViewModelProvider(this, viewModelFactory)[DuckChatWebViewViewModel::class.java] @@ -112,6 +114,7 @@ class DuckChatContextualBottomSheet( simpleWebview = binding.simpleWebview inputControls = binding.inputModeWidgetCard promptsList = binding.contextualModePrompts + webViewContainer = binding.contextualWebViewContainer configureViews(binding) return binding.root } @@ -119,7 +122,15 @@ class DuckChatContextualBottomSheet( private fun configureViews(binding: BottomSheetDuckAiContextualBinding) { val bottomSheetDialog = dialog as? BottomSheetDialog bottomSheetDialog?.let { + it.behavior.isFitToContents = false + it.behavior.expandedOffset = 0 it.behavior.state = BottomSheetBehavior.STATE_COLLAPSED + binding.root.doOnLayout { + val peekHeight = calculatePeekHeight(binding) + if (peekHeight > 0) { + bottomSheetDialog.behavior.peekHeight = peekHeight + } + } // Set up callback to prevent collapsing after expansion it.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { @@ -150,6 +161,7 @@ class DuckChatContextualBottomSheet( private fun expandSheet() { inputControls.gone() promptsList.gone() + webViewContainer.visibility = View.VISIBLE val bottomSheetDialog = dialog as? BottomSheetDialog bottomSheetDialog?.let { it.behavior.state = BottomSheetBehavior.STATE_EXPANDED @@ -324,6 +336,21 @@ class DuckChatContextualBottomSheet( // 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 { 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 index 4c647330311b..375a6cd1367c 100644 --- 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 @@ -19,7 +19,7 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/inputModeWidgetLayout" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="match_parent"> + + + app:layout_constraintTop_toBottomOf="@+id/contextualModeButtons" + app:layout_constraintBottom_toBottomOf="parent"> diff --git a/duckchat/duckchat-impl/src/main/res/values/widgets.xml b/duckchat/duckchat-impl/src/main/res/values/widgets.xml index 82c704ddc634..7ee65a14a512 100644 --- a/duckchat/duckchat-impl/src/main/res/values/widgets.xml +++ b/duckchat/duckchat-impl/src/main/res/values/widgets.xml @@ -24,7 +24,6 @@ false true true - 300dp From 687bde5340c9a0027f334eb69915e152122043a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Tue, 16 Dec 2025 23:59:40 +0100 Subject: [PATCH 13/26] adding spacer after keyboard has focus --- .../impl/ui/DuckChatContextualBottomSheet.kt | 39 +++++++++++++------ .../bottom_sheet_duck_ai_contextual.xml | 24 ++---------- 2 files changed, 31 insertions(+), 32 deletions(-) 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 index 5ee500615d98..47e23e3f27a1 100644 --- 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 @@ -31,6 +31,7 @@ import android.webkit.WebView import androidx.annotation.AnyThread import androidx.core.content.ContextCompat import androidx.core.view.doOnLayout +import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.duckduckgo.app.tabs.BrowserNav @@ -122,7 +123,6 @@ class DuckChatContextualBottomSheet( private fun configureViews(binding: BottomSheetDuckAiContextualBinding) { val bottomSheetDialog = dialog as? BottomSheetDialog bottomSheetDialog?.let { - it.behavior.isFitToContents = false it.behavior.expandedOffset = 0 it.behavior.state = BottomSheetBehavior.STATE_COLLAPSED binding.root.doOnLayout { @@ -132,19 +132,27 @@ class DuckChatContextualBottomSheet( } } // Set up callback to prevent collapsing after expansion - it.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - isExpanded = true - // Set skipCollapsed to prevent dragging back to collapsed state - it.behavior.skipCollapsed = true - it.behavior.isDraggable = true + it.behavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged( + bottomSheet: View, + newState: Int, + ) { + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + isExpanded = true + // Set skipCollapsed to prevent dragging back to collapsed state + it.behavior.skipCollapsed = true + it.behavior.isDraggable = true + } } - } - override fun onSlide(bottomSheet: View, slideOffset: Float) { - } - }) + override fun onSlide( + bottomSheet: View, + slideOffset: Float, + ) { + } + }, + ) } configureDialogButtons(binding) } @@ -156,6 +164,13 @@ class DuckChatContextualBottomSheet( binding.actionSend.setOnClickListener { expandSheet() } + binding.inputField.setOnFocusChangeListener { _, hasFocus -> + binding.contextualModeInputSpacer.isVisible = hasFocus + val bottomSheetDialog = dialog as? BottomSheetDialog + bottomSheetDialog?.let { + it.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } } private fun expandSheet() { 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 index 375a6cd1367c..72f07aa0a415 100644 --- 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 @@ -87,9 +87,11 @@ android:id="@+id/contextualModeInputSpacer" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" + android:visibility="gone" app:layout_constraintTop_toBottomOf="@+id/contextualModeButtons" + app:layout_constraintBottom_toTopOf="@+id/contextualModePrompts" android:layout_width="match_parent" - android:layout_height="0dp" /> + android:layout_height="200dp" /> - - - - Date: Wed, 17 Dec 2025 15:03:12 +0100 Subject: [PATCH 14/26] more tries at keyboard --- .../app/browser/BrowserTabFragment.kt | 2 + .../impl/ui/DuckChatContextualBottomSheet.kt | 28 ++++++- .../impl/ui/InputBottomSheetFragment.kt | 76 +++++++++++++++++++ .../bottom_sheet_duck_ai_contextual.xml | 12 ++- .../cat_bottomsheet_unscrollable_content.xml | 74 ++++++++++++++++++ 5 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/InputBottomSheetFragment.kt create mode 100644 duckchat/duckchat-impl/src/main/res/layout/cat_bottomsheet_unscrollable_content.xml 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 100246f6bc24..d8275ea58fbd 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -3132,6 +3132,8 @@ class BrowserTabFragment : } private fun showDuckChatBottomSheet() { + // InputBottomSheetFragment().show(getParentFragmentManager(), "") + val duckChatContextualSheet = duckChatContextualBottomSheetFactory.create() duckChatContextualSheet.show(childFragmentManager, DuckChatContextualBottomSheet.TAG) } 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 index 47e23e3f27a1..a8bcaf80c737 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -30,8 +31,11 @@ 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.isVisible +import androidx.core.view.updatePadding import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.duckduckgo.app.tabs.BrowserNav @@ -102,8 +106,23 @@ class DuckChatContextualBottomSheet( private val downloadMessagesJob = ConflatedJob() private var isExpanded = false - override fun getTheme(): Int { - return R.style.DuckChatBottomSheetDialogTheme + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + // Set up BottomSheetDialog + val bottomSheetDialog = BottomSheetDialog(requireContext(), R.style.DuckChatBottomSheetDialogTheme) + + bottomSheetDialog.window?.let { + ViewCompat.setOnApplyWindowInsetsListener(it.decorView) { view, insets -> + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + val systemBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + val extraMargin = (imeBottom).coerceAtLeast(0) + + view.updatePadding(bottom = extraMargin) + insets + } + ViewCompat.requestApplyInsets(it.decorView) + } + + return bottomSheetDialog } override fun onCreateView( @@ -124,7 +143,7 @@ class DuckChatContextualBottomSheet( val bottomSheetDialog = dialog as? BottomSheetDialog bottomSheetDialog?.let { it.behavior.expandedOffset = 0 - it.behavior.state = BottomSheetBehavior.STATE_COLLAPSED + it.behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED binding.root.doOnLayout { val peekHeight = calculatePeekHeight(binding) if (peekHeight > 0) { @@ -165,10 +184,11 @@ class DuckChatContextualBottomSheet( expandSheet() } binding.inputField.setOnFocusChangeListener { _, hasFocus -> - binding.contextualModeInputSpacer.isVisible = hasFocus val bottomSheetDialog = dialog as? BottomSheetDialog bottomSheetDialog?.let { it.behavior.state = BottomSheetBehavior.STATE_EXPANDED + binding.contextualModeInputSpacer.isVisible = hasFocus + binding.contextualModeInputBottomSpacer.isVisible = hasFocus } } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/InputBottomSheetFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/InputBottomSheetFragment.kt new file mode 100644 index 000000000000..f8ebcef79e13 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/InputBottomSheetFragment.kt @@ -0,0 +1,76 @@ +/* + * 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.app.Dialog +import android.os.Bundle +import android.view.View +import android.widget.Button +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type +import androidx.core.view.updatePadding +import com.duckduckgo.duckchat.impl.R +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.R as MaterialR + +class InputBottomSheetFragment : BottomSheetDialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + // Set up BottomSheetDialog + val bottomSheetDialog = BottomSheetDialog(requireContext()) + // WindowPreferencesManager(requireContext()).applyEdgeToEdgePreference(bottomSheetDialog.getWindow()) + + bottomSheetDialog.window?.let { + ViewCompat.setOnApplyWindowInsetsListener(it.decorView) { view, insets -> + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + val systemBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + val extraMargin = (imeBottom).coerceAtLeast(0) + + view.updatePadding(bottom = extraMargin) + insets + } + ViewCompat.requestApplyInsets(it.decorView) + } + + bottomSheetDialog.setContentView(R.layout.cat_bottomsheet_unscrollable_content) + val bottomSheetInternal = bottomSheetDialog.findViewById(MaterialR.id.design_bottom_sheet) + + // BottomSheetBehavior.from(bottomSheetInternal).setPeekHeight(400); + val closeButton = bottomSheetDialog.findViewById(R.id.close_icon) + closeButton!!.setOnClickListener(View.OnClickListener { v: View? -> bottomSheetDialog.dismiss() }) + + val bottomSheetContent = bottomSheetInternal!!.findViewById(R.id.bottom_drawer_3) + + // ViewUtils.doOnApplyWindowInsets( + // bottomSheetContent, + // ViewUtils.OnApplyWindowInsetsListener { v: View?, insets: WindowInsetsCompat?, initialPadding: RelativePadding? -> + // // Add the inset in the inner NestedScrollView instead to make the edge-to-edge behavior + // // consistent - i.e., the extra padding will only show at the bottom of all content, i.e., + // // only when you can no longer scroll down to show more content. + // bottomSheetContent.setPaddingRelative( + // initialPadding!!.start, + // initialPadding.top, + // initialPadding.end, + // initialPadding.bottom + insets!!.getInsets(WindowInsetsCompat.Type.systemBars()).bottom, + // ) + // insets + // }, + // ) + return bottomSheetDialog + } +} 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 index 72f07aa0a415..d2c5bb00a3d2 100644 --- 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 @@ -126,7 +126,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/contextualModePrompts" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@+id/contextualModeInputBottomSpacer" android:layout_marginHorizontal="@dimen/keyline_4" android:layout_marginTop="@dimen/keyline_2" android:layout_marginBottom="@dimen/keyline_4" @@ -213,6 +213,16 @@ + + + + + + + + +