diff --git a/android-design-system/design-system/src/main/res/drawable/duck_ai_prompt_background.xml b/android-design-system/design-system/src/main/res/drawable/duck_ai_prompt_background.xml new file mode 100644 index 000000000000..17a31333d541 --- /dev/null +++ b/android-design-system/design-system/src/main/res/drawable/duck_ai_prompt_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/android-design-system/design-system/src/main/res/drawable/ic_arrow_down_right_16.xml b/android-design-system/design-system/src/main/res/drawable/ic_arrow_down_right_16.xml new file mode 100644 index 000000000000..7f06497ffaa0 --- /dev/null +++ b/android-design-system/design-system/src/main/res/drawable/ic_arrow_down_right_16.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/android-design-system/design-system/src/main/res/drawable/ic_duck_ai_color_24.xml b/android-design-system/design-system/src/main/res/drawable/ic_duck_ai_color_24.xml new file mode 100644 index 000000000000..9cb48027adac --- /dev/null +++ b/android-design-system/design-system/src/main/res/drawable/ic_duck_ai_color_24.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/android-design-system/design-system/src/main/res/drawable/ic_expand_24.xml b/android-design-system/design-system/src/main/res/drawable/ic_expand_24.xml new file mode 100644 index 000000000000..bdb285c02e50 --- /dev/null +++ b/android-design-system/design-system/src/main/res/drawable/ic_expand_24.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 985feecc15a9..dc1252d7aa8f 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -456,6 +456,8 @@ class BrowserTabViewModelTest { private val mockDuckAiFeatureStateFullScreenModeFlow = MutableStateFlow(false) + private val mockDuckAiContextualModeFlow = MutableStateFlow(false) + private val mockExternalIntentProcessingState: ExternalIntentProcessingState = mock() private val mockVpnMenuStateProvider: VpnMenuStateProvider = mock() @@ -678,6 +680,7 @@ class BrowserTabViewModelTest { whenever(mockDuckAiFeatureState.showInputScreen).thenReturn(mockDuckAiFeatureStateInputScreenFlow) whenever(mockDuckAiFeatureState.showInputScreenAutomaticallyOnNewTab).thenReturn(mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow) whenever(mockDuckAiFeatureState.showFullScreenMode).thenReturn(mockDuckAiFeatureStateFullScreenModeFlow) + whenever(mockDuckAiFeatureState.showContextualMode).thenReturn(mockDuckAiContextualModeFlow) whenever(mockExternalIntentProcessingState.hasPendingTabLaunch).thenReturn(mockHasPendingTabLaunchFlow) whenever(mockExternalIntentProcessingState.hasPendingDuckAiOpen).thenReturn(mockHasPendingDuckAiOpenFlow) whenever(mockVpnMenuStateProvider.getVpnMenuState()).thenReturn(flowOf(VpnMenuState.Hidden)) @@ -8241,4 +8244,33 @@ class BrowserTabViewModelTest { verify(mockPixel).fire(DuckChatPixelName.PRODUCT_TELEMETRY_SURFACE_KEYBOARD_USAGE) verify(mockPixel).fire(DuckChatPixelName.PRODUCT_TELEMETRY_SURFACE_KEYBOARD_USAGE_DAILY, type = Daily()) } + + @Test + fun whenOnDuckChatOmnibarButtonClickedAndContextualModeEnabledAndNotNTPThenCommandSent() = runTest { + mockDuckAiContextualModeFlow.emit(true) + + testee.onDuckChatOmnibarButtonClicked(query = "example", hasFocus = false, isNtp = false) + + verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + assertTrue(commandCaptor.lastValue is Command.ShowDuckAIContextualMode) + } + + @Test + fun whenOnDuckChatOmnibarButtonClickedAndContextualModeEnabledAndNTPThenContextualNotCalled() = runTest { + val duckAIUrl = "https://duckduckgo.com/?q=test" + + mockDuckAiContextualModeFlow.emit(true) + mockDuckAiFeatureStateFullScreenModeFlow.emit(true) + + whenever(mockDuckChat.getDuckChatUrl(any(), any())).thenReturn(duckAIUrl) + whenever(mockOmnibarConverter.convertQueryToUrl(duckAIUrl, null)).thenReturn(duckAIUrl) + + testee.onDuckChatOmnibarButtonClicked(query = "example", hasFocus = false, isNtp = true) + + verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + val command = commandCaptor.lastValue as Navigate + assertEquals(duckAIUrl, command.url) + + verify(mockDuckChat, never()).openDuckChat() + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index a8f81217f0f4..89330b452853 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -296,6 +296,7 @@ import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityResultCodes import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityResultParams import com.duckduckgo.duckchat.api.inputscreen.InputScreenBrowserButtonsConfig import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName +import com.duckduckgo.duckchat.impl.ui.DuckChatContextualFragment import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams import com.duckduckgo.js.messaging.api.JsCallbackData @@ -368,6 +369,8 @@ class BrowserTabFragment : EmailProtectionUserPromptListener { private val supervisorJob = SupervisorJob() + private var duckAiContextualFragment: DuckChatContextualFragment? = null + override val coroutineContext: CoroutineContext get() = supervisorJob + dispatchers.main() @@ -864,6 +867,8 @@ class BrowserTabFragment : private var automaticFireproofDialog: DaxAlertDialog? = null + private var duckChatContextualSheet: DuckChatContextualFragment? = null + private var webShareRequest = registerForActivityResult(WebShareChooser()) { contentScopeScripts.onResponse(it) @@ -2401,6 +2406,7 @@ class BrowserTabFragment : is Command.PageStarted -> onPageStarted() is Command.EnableDuckAIFullScreen -> showDuckAI(it.browserViewState) is Command.DisableDuckAIFullScreen -> omnibar.setViewMode(ViewMode.Browser(it.url)) + is Command.ShowDuckAIContextualMode -> showDuckChatBottomSheet() } } @@ -3104,10 +3110,6 @@ class BrowserTabFragment : } override fun onDuckChatButtonPressed() { - if (!duckAiFeatureState.showInputScreen.value) { - pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED) - pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED_DAILY, type = Daily()) - } val hasFocus = omnibar.omnibarTextInput.hasFocus() val isNtp = omnibar.viewMode == ViewMode.NewTab onOmnibarDuckChatPressed(query = omnibar.getText(), hasFocus = hasFocus, isNtp = isNtp) @@ -3125,6 +3127,38 @@ class BrowserTabFragment : ) } + private fun showDuckChatBottomSheet() { + duckAiContextualFragment?.let { fragment -> + val transaction = childFragmentManager.beginTransaction() + transaction.show(fragment) + transaction.commit() + } ?: run { + val fragment = DuckChatContextualFragment() + duckAiContextualFragment = fragment + val transaction = childFragmentManager.beginTransaction() + transaction.replace(binding.duckAiFragmentContainer.id, fragment) + transaction.commit() + } + + binding.duckAiFragmentContainer.show() + + val bottomSheetBehavior = BottomSheetBehavior.from(binding.duckAiFragmentContainer) + bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + bottomSheetBehavior.isShouldRemoveExpandedCorners = false + bottomSheetBehavior.skipCollapsed = true + bottomSheetBehavior.isDraggable = true + bottomSheetBehavior.isHideable = true + + bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + binding.duckAiFragmentContainer.gone() + } + } + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + }) + } + private fun configureOmnibarTextInput() { omnibar.addTextListener( object : TextListener { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index c57cf1c45681..bf4832f41e17 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -4469,18 +4469,31 @@ class BrowserTabViewModel @Inject constructor( command.value = HideKeyboardForChat } - if (duckAiFeatureState.showFullScreenMode.value) { - val url = when { - hasFocus && isNtp && query.isNullOrBlank() -> duckChat.getDuckChatUrl(query ?: "", false) - hasFocus -> duckChat.getDuckChatUrl(query ?: "", true) - else -> duckChat.getDuckChatUrl(query ?: "", false) + if (!duckAiFeatureState.showInputScreen.value) { + pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED) + pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED_DAILY, type = Daily()) + } + + when { + duckAiFeatureState.showContextualMode.value && !isNtp -> { + command.value = Command.ShowDuckAIContextualMode } - onUserSubmittedQuery(url) - } else { - when { - hasFocus && isNtp && query.isNullOrBlank() -> duckChat.openDuckChat() - hasFocus -> duckChat.openDuckChatWithAutoPrompt(query ?: "") - else -> duckChat.openDuckChat() + + duckAiFeatureState.showFullScreenMode.value -> { + val url = when { + hasFocus && isNtp && query.isNullOrBlank() -> duckChat.getDuckChatUrl(query ?: "", false) + hasFocus -> duckChat.getDuckChatUrl(query ?: "", true) + else -> duckChat.getDuckChatUrl(query ?: "", false) + } + onUserSubmittedQuery(url) + } + + else -> { + when { + hasFocus && isNtp && query.isNullOrBlank() -> duckChat.openDuckChat() + hasFocus -> duckChat.openDuckChatWithAutoPrompt(query ?: "") + else -> duckChat.openDuckChat() + } } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt index 261e0fed5936..f23e33629215 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt @@ -503,4 +503,6 @@ sealed class Command { data class EnableDuckAIFullScreen(val browserViewState: BrowserViewState) : Command() data class DisableDuckAIFullScreen(val url: String) : Command() + + data object ShowDuckAIContextualMode : Command() } diff --git a/app/src/main/res/layout/fragment_browser_tab.xml b/app/src/main/res/layout/fragment_browser_tab.xml index d9e0aec9ddfc..849015dcece9 100644 --- a/app/src/main/res/layout/fragment_browser_tab.xml +++ b/app/src/main/res/layout/fragment_browser_tab.xml @@ -142,6 +142,16 @@ + + >? = null + + private val root: ViewGroup by lazy { binding.root } + + internal val simpleWebview: WebView by lazy { binding.simpleWebview } + + // this will go in the viewmodel + private enum class SheetMode { + INPUT, + WEBVIEW, + } + + private var sheetMode = SheetMode.WEBVIEW + + @SuppressLint("SetJavaScriptEnabled") + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + val url = arguments?.getString(KEY_DUCK_AI_URL) ?: "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5" + + simpleWebview.let { + it.webViewClient = webViewClient + it.webChromeClient = object : WebChromeClient() { + override fun onCreateWindow( + view: WebView?, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message?, + ): Boolean { + view?.requestFocusNodeHref(resultMsg) + val newWindowUrl = resultMsg?.data?.getString("url") + if (newWindowUrl != null) { + if (viewModel.handleOnSameWebView(newWindowUrl)) { + simpleWebview.loadUrl(newWindowUrl) + } else { + startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl)) + } + return true + } + return false + } + + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams, + ): Boolean { + return try { + showFileChooser(filePathCallback, fileChooserParams) + true + } catch (e: Throwable) { + // cancel the request using the documented way + filePathCallback.onReceiveValue(null) + throw e + } + } + } + + it.settings.apply { + userAgentString = CUSTOM_UA + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + setSupportMultipleWindows(true) + databaseEnabled = false + setSupportZoom(true) + } + + it.setDownloadListener { url, _, contentDisposition, mimeType, _ -> + appCoroutineScope.launch(dispatcherProvider.io()) { + if (aiChatDownloadFeature.self().isEnabled()) { + requestFileDownload(url, contentDisposition, mimeType) + } + } + } + + contentScopeScripts.register( + it, + object : JsMessageCallback() { + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + logcat { "Duck.ai: process $featureName $method $id $data" } + when (featureName) { + DUCK_CHAT_FEATURE_NAME -> { + appCoroutineScope.launch(dispatcherProvider.io()) { + duckChatJSHelper.processJsCallbackMessage(featureName, method, id, data)?.let { response -> + withContext(dispatcherProvider.main()) { + if (response.method == METHOD_OPEN_KEYBOARD) { + simpleWebview.evaluateJavascript( + response.params.get(SELECTOR).toString(), + null, + ) + showSoftKeyboard() + } + contentScopeScripts.onResponse(response) + } + } + } + } + + SUBSCRIPTIONS_FEATURE_NAME -> { + subscriptionsHandler.handleSubscriptionsFeature( + featureName, + method, + id, + data, + requireActivity(), + appCoroutineScope, + contentScopeScripts, + ) + } + + else -> {} + } + } + }, + ) + } + + url?.let { + simpleWebview.loadUrl(it) + } + + externalCameraLauncher.registerForResult(this) { + when (it) { + is MediaCaptured -> pendingUploadTask?.onReceiveValue(arrayOf(Uri.fromFile(it.file))) + is CouldNotCapturePermissionDenied -> { + pendingUploadTask?.onReceiveValue(null) + externalCameraLauncher.showPermissionRationaleDialog(requireActivity(), it.inputAction) + } + + is NoMediaCaptured -> pendingUploadTask?.onReceiveValue(null) + is ErrorAccessingMediaApp -> { + pendingUploadTask?.onReceiveValue(null) + Snackbar.make(root, it.messageId, BaseTransientBottomBar.LENGTH_SHORT).show() + } + } + pendingUploadTask = null + } + configureBottomSheet(view) + observeViewModel() + } + + private fun configureBottomSheet(view: View) { + val parent = view.parent as? View ?: return + val bottomSheetBehavior = BottomSheetBehavior.from(parent) + binding.contextualClose.setOnClickListener { + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + } + binding.contextualModeButtons.setOnClickListener { } + } + + private fun observeViewModel() { + viewModel.commands + .onEach { command -> + when (command) { + is DuckChatWebViewViewModel.Command.SendSubscriptionAuthUpdateEvent -> { + val authUpdateEvent = SubscriptionEventData( + featureName = SUBSCRIPTIONS_FEATURE_NAME, + subscriptionName = "authUpdate", + params = JSONObject(), + ) + contentScopeScripts.sendSubscriptionEvent(authUpdateEvent) + } + } + }.launchIn(lifecycleScope) + } + + private fun launchInputScreen() { + // val isTopOmnibar = omnibar.omnibarType != OmnibarType.SINGLE_BOTTOM + // TODO: Support Bottom / Split omnibar + val isTopOmnibar = true + val tabs = arguments?.getInt(KEY_DUCK_AI_TABS) ?: 0 + val intent = + globalActivityStarter.startIntent( + requireContext(), + InputScreenActivityParams( + query = "", + isTopOmnibar = true, + browserButtonsConfig = InputScreenBrowserButtonsConfig.Enabled(tabs = tabs), + ), + ) + val enterTransition = browserAndInputScreenTransitionProvider.getInputScreenEnterAnimation(true) + val exitTransition = browserAndInputScreenTransitionProvider.getBrowserExitAnimation(isTopOmnibar) + val options = + ActivityOptionsCompat.makeCustomAnimation( + requireActivity(), + enterTransition, + exitTransition, + ) + inputScreenLauncher.launch(intent, options) + } + + private val inputScreenLauncher = + registerForActivityResult(StartActivityForResult()) { result -> + val data = result.data + when (result.resultCode) { + InputScreenActivityResultCodes.NEW_SEARCH_REQUESTED -> { + data?.getStringExtra(InputScreenActivityResultParams.SEARCH_QUERY_PARAM)?.let { query -> + browseSharedViewModel.onSearchRequested(query) + } + } + + InputScreenActivityResultCodes.SWITCH_TO_TAB_REQUESTED -> { + data?.getStringExtra(InputScreenActivityResultParams.TAB_ID_PARAM)?.let { tabId -> + browseSharedViewModel.openExistingTab(tabId) + } + } + + InputScreenActivityResultCodes.MENU_REQUESTED -> { + } + + InputScreenActivityResultCodes.TAB_SWITCHER_REQUESTED -> { + browseSharedViewModel.onTabSwitcherClicked() + } + + InputScreenActivityResultCodes.FIRE_BUTTON_REQUESTED -> { + browseSharedViewModel.onFireButtonClicked() + } + } + } + + data class FileChooserRequestedParams( + val filePickingMode: Int, + val acceptMimeTypes: List, + ) + + private fun showSoftKeyboard() { + simpleWebview.requestFocus() + val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(simpleWebview, InputMethodManager.SHOW_IMPLICIT) + } + + private fun hideSoftKeyboard() { + val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(simpleWebview.windowToken, 0) + } + + fun showFileChooser( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams, + ) { + val mimeTypes = convertAcceptTypesToMimeTypes(fileChooserParams.acceptTypes) + val fileChooserRequestedParams = FileChooserRequestedParams(fileChooserParams.mode, mimeTypes) + val cameraHardwareAvailable = cameraHardwareChecker.hasCameraHardware() + + when { + fileChooserParams.isCaptureEnabled -> { + when { + acceptsOnly("image/", fileChooserParams.acceptTypes) && cameraHardwareAvailable -> + launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_IMAGE_CAPTURE) + + acceptsOnly("video/", fileChooserParams.acceptTypes) && cameraHardwareAvailable -> + launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE) + + acceptsOnly("audio/", fileChooserParams.acceptTypes) -> + launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.Audio.Media.RECORD_SOUND_ACTION) + + else -> + launchFilePicker(filePathCallback, fileChooserRequestedParams) + } + } + + fileChooserParams.acceptTypes.any { it.startsWith("image/") && cameraHardwareAvailable } -> + launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_IMAGE_CAPTURE) + + fileChooserParams.acceptTypes.any { it.startsWith("video/") && cameraHardwareAvailable } -> + launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE) + + else -> + launchFilePicker(filePathCallback, fileChooserRequestedParams) + } + } + + private fun launchFilePicker( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserRequestedParams, + ) { + pendingUploadTask = filePathCallback + val canChooseMultipleFiles = fileChooserParams.filePickingMode == FileChooserParams.MODE_OPEN_MULTIPLE + val intent = fileChooserIntentBuilder.intent(fileChooserParams.acceptMimeTypes.toTypedArray(), canChooseMultipleFiles) + startActivityForResult(intent, REQUEST_CODE_CHOOSE_FILE) + } + + private fun launchCameraCapture( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserRequestedParams, + inputAction: String, + ) { + if (Intent(inputAction).resolveActivity(requireActivity().packageManager) == null) { + launchFilePicker(filePathCallback, fileChooserParams) + return + } + + pendingUploadTask = filePathCallback + externalCameraLauncher.launch(inputAction) + } + + private fun launchImageOrCameraChooser( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserRequestedParams, + inputAction: String, + ) { + val cameraString = getString(R.string.imageCaptureCameraGalleryDisambiguationCameraOption) + val cameraIcon = com.duckduckgo.mobile.android.R.drawable.ic_camera_24 + + val galleryString = getString(R.string.imageCaptureCameraGalleryDisambiguationGalleryOption) + val galleryIcon = com.duckduckgo.mobile.android.R.drawable.ic_image_24 + + ActionBottomSheetDialog.Builder(requireContext()) + .setTitle(getString(R.string.imageCaptureCameraGalleryDisambiguationTitle)) + .setPrimaryItem(galleryString, galleryIcon) + .setSecondaryItem(cameraString, cameraIcon) + .addEventListener( + object : ActionBottomSheetDialog.EventListener() { + override fun onPrimaryItemClicked() { + launchFilePicker(filePathCallback, fileChooserParams) + } + + override fun onSecondaryItemClicked() { + launchCameraCapture(filePathCallback, fileChooserParams, inputAction) + } + + override fun onBottomSheetDismissed() { + filePathCallback.onReceiveValue(null) + pendingUploadTask = null + } + }, + ) + .show() + } + + private fun acceptsOnly( + type: String, + acceptTypes: Array, + ): Boolean { + return acceptTypes.filter { it.startsWith(type) }.size == acceptTypes.size + } + + private fun convertAcceptTypesToMimeTypes(acceptTypes: Array): List { + val mimeTypeMap = MimeTypeMap.getSingleton() + val mimeTypes = mutableSetOf() + acceptTypes.forEach { type -> + // Attempt to convert any identified file extensions into corresponding MIME types. + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(type) + if (fileExtension.isNotEmpty()) { + mimeTypeMap.getMimeTypeFromExtension(type.substring(1))?.let { + mimeTypes.add(it) + } + } else { + mimeTypes.add(type) + } + } + return mimeTypes.toList() + } + + fun onBackPressed(): Boolean { + if (!isVisible) return false + + if (!simpleWebview.canGoBack()) { + exit() + return true + } + + val history = simpleWebview.copyBackForwardList() + if (viewModel.shouldCloseDuckChat(history)) { + exit() + } else { + simpleWebview.goBack() + } + return true + } + + private fun exit() { + hideSoftKeyboard() + duckChat.closeDuckChat() + } + + override fun continueDownload(pendingFileDownload: PendingFileDownload) { + fileDownloader.enqueueDownload(pendingFileDownload) + } + + override fun cancelDownload() { + // NOOP + } + + private fun launchDownloadMessagesJob() { + downloadMessagesJob += lifecycleScope.launch { + downloadCallback.commands().cancellable().collect { + processFileDownloadedCommand(it) + } + } + } + + private fun processFileDownloadedCommand(command: DownloadCommand) { + when (command) { + is DownloadCommand.ShowDownloadStartedMessage -> downloadStarted(command) + is DownloadCommand.ShowDownloadFailedMessage -> downloadFailed(command) + is DownloadCommand.ShowDownloadSuccessMessage -> downloadSucceeded(command) + } + } + + @SuppressLint("WrongConstant") + private fun downloadStarted(command: DownloadCommand.ShowDownloadStartedMessage) { + root.makeSnackbarWithNoBottomInset(getString(command.messageId, command.fileName), DOWNLOAD_SNACKBAR_LENGTH)?.show() + } + + private fun downloadFailed(command: DownloadCommand.ShowDownloadFailedMessage) { + val downloadFailedSnackbar = root.makeSnackbarWithNoBottomInset(getString(command.messageId), Snackbar.LENGTH_LONG) + root.postDelayed({ downloadFailedSnackbar.show() }, DOWNLOAD_SNACKBAR_DELAY) + } + + private fun downloadSucceeded(command: DownloadCommand.ShowDownloadSuccessMessage) { + val downloadSucceededSnackbar = root.makeSnackbarWithNoBottomInset( + getString(command.messageId, command.fileName), + Snackbar.LENGTH_LONG, + ) + .apply { + this.setAction(R.string.duck_chat_download_finished_action_name) { + val result = downloadsFileActions.openFile(context, File(command.filePath)) + if (!result) { + view.makeSnackbarWithNoBottomInset(getString(R.string.duck_chat_cannot_open_file_error_message), Snackbar.LENGTH_LONG).show() + } + } + } + root.postDelayed({ downloadSucceededSnackbar.show() }, DOWNLOAD_SNACKBAR_DELAY) + } + + private fun requestFileDownload( + url: String, + contentDisposition: String?, + mimeType: String, + ) { + pendingFileDownload = PendingFileDownload( + url = url, + contentDisposition = contentDisposition, + mimeType = mimeType, + subfolder = Environment.DIRECTORY_DOWNLOADS, + fileName = "duck.ai_${System.currentTimeMillis()}", + ) + + if (hasWriteStoragePermission()) { + downloadFile() + } else { + requestWriteStoragePermission() + } + } + + @AnyThread + private fun downloadFile() { + val pendingDownload = pendingFileDownload ?: return + + pendingFileDownload = null + + continueDownload(pendingDownload) + } + + private fun minSdk30(): Boolean { + return appBuildConfig.sdkInt >= 30 + } + + @Suppress("NewApi") + private fun hasWriteStoragePermission(): Boolean { + return minSdk30() || + ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + } + + private fun requestWriteStoragePermission() { + requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE) + } + + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent?, + ) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_CODE_CHOOSE_FILE) { + handleFileUploadResult(resultCode, data) + } + } + + private fun handleFileUploadResult( + resultCode: Int, + intent: Intent?, + ) { + if (resultCode != RESULT_OK || intent == null) { + pendingUploadTask?.onReceiveValue(null) + return + } + + val uris = fileChooserIntentBuilder.extractSelectedFileUris(intent) + pendingUploadTask?.onReceiveValue(uris) + } + + override fun onResume() { + simpleWebview.onResume() + super.onResume() + launchDownloadMessagesJob() + } + + override fun onPause() { + downloadMessagesJob.cancel() + simpleWebview.onPause() + appCoroutineScope.launch(dispatcherProvider.io()) { + cookieManager.flush() + } + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + appCoroutineScope.launch(dispatcherProvider.io()) { + cookieManager.flush() + } + } + + companion object { + private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200 + private const val CUSTOM_UA = + "Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile DuckDuckGo/5 Safari/537.36" + const val REQUEST_CODE_CHOOSE_FILE = 100 + const val KEY_DUCK_AI_URL: String = "KEY_DUCK_AI_URL" + const val KEY_DUCK_AI_TABS: String = "KEY_DUCK_AI_TABS" + } +} diff --git a/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai.xml b/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai.xml new file mode 100644 index 000000000000..c08d56e2d0bd --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/fragment_contextual_duck_ai.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/duckchat/duckchat-impl/src/main/res/values/dimens.xml b/duckchat/duckchat-impl/src/main/res/values/dimens.xml index bffad1683b6a..99d9023d631b 100644 --- a/duckchat/duckchat-impl/src/main/res/values/dimens.xml +++ b/duckchat/duckchat-impl/src/main/res/values/dimens.xml @@ -21,4 +21,5 @@ 0dp 64dp 0.5dp + 80dp \ No newline at end of file diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml new file mode 100644 index 000000000000..f84557c92ca1 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -0,0 +1,24 @@ + + + + + + Summarize this Page + Explain in simpler terms + Suggest related articles to explore + + \ No newline at end of file diff --git a/duckchat/duckchat-impl/src/main/res/values/widgets.xml b/duckchat/duckchat-impl/src/main/res/values/widgets.xml new file mode 100644 index 000000000000..24e3f488335f --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/values/widgets.xml @@ -0,0 +1,30 @@ + + + + + + + + +