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