From 4fb328fab1a3a9baf989b48bdb9925455aaf91b8 Mon Sep 17 00:00:00 2001 From: Anastasia Shuba Date: Tue, 17 Dec 2024 14:24:05 -0800 Subject: [PATCH 01/16] initial chat entry point --- app/build.gradle | 2 + .../app/browser/BrowserTabFragment.kt | 9 +- .../app/browser/BrowserTabViewModel.kt | 7 ++ .../app/browser/menu/BrowserPopupMenu.kt | 8 ++ .../app/browser/viewstate/BrowserViewState.kt | 1 + .../app/settings/NewSettingsActivity.kt | 13 +++ .../app/settings/NewSettingsViewModel.kt | 10 ++ .../app/tabs/ui/TabSwitcherActivity.kt | 15 +++ app/src/main/res/drawable/ic_ai_chat_16.xml | 33 ++++++ app/src/main/res/drawable/ic_new_pill.xml | 12 ++ .../layout/content_settings_main_settings.xml | 8 ++ .../res/layout/popup_window_browser_menu.xml | 4 + .../popup_window_browser_menu_bottom.xml | 4 + .../res/layout/view_menu_item_new_feature.xml | 53 +++++++++ .../res/menu/menu_tab_switcher_activity.xml | 5 + app/src/main/res/values/donottranslate.xml | 3 + .../src/main/res/drawable/ic_ai_chat_24.xml | 35 ++++++ duckchat/duckchat-api/.gitignore | 0 duckchat/duckchat-api/build.gradle | 40 +++++++ .../com/duckduckgo/duckchat/api/DuckChat.kt | 43 +++++++ .../duckchat/api/DuckChatSettingsScreens.kt | 24 ++++ duckchat/duckchat-impl/build.gradle | 79 +++++++++++++ duckchat/duckchat-impl/lint-baseline.xml | 4 + .../src/main/AndroidManifest.xml | 26 +++++ .../duckchat/impl/DuckChatDataStore.kt | 72 ++++++++++++ .../duckchat/impl/DuckChatDataStoreModule.kt | 43 +++++++ .../duckchat/impl/DuckChatFeature.kt | 37 ++++++ .../impl/DuckChatFeatureRepository.kt | 48 ++++++++ .../duckchat/impl/DuckChatSettingsActivity.kt | 99 ++++++++++++++++ .../impl/DuckChatSettingsViewModel.kt | 67 +++++++++++ .../duckduckgo/duckchat/impl/RealDuckChat.kt | 110 ++++++++++++++++++ .../main/res/drawable/chat_private_128.xml | 31 +++++ .../layout/activity_duck_chat_settings.xml | 80 +++++++++++++ .../src/main/res/values/donottranslate.xml | 20 ++++ duckchat/readme.md | 11 ++ 35 files changed, 1054 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/ic_ai_chat_16.xml create mode 100644 app/src/main/res/drawable/ic_new_pill.xml create mode 100644 app/src/main/res/layout/view_menu_item_new_feature.xml create mode 100644 common/common-ui/src/main/res/drawable/ic_ai_chat_24.xml create mode 100644 duckchat/duckchat-api/.gitignore create mode 100644 duckchat/duckchat-api/build.gradle create mode 100644 duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt create mode 100644 duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatSettingsScreens.kt create mode 100644 duckchat/duckchat-impl/build.gradle create mode 100644 duckchat/duckchat-impl/lint-baseline.xml create mode 100644 duckchat/duckchat-impl/src/main/AndroidManifest.xml create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatDataStore.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatDataStoreModule.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeature.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeatureRepository.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsActivity.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsViewModel.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt create mode 100644 duckchat/duckchat-impl/src/main/res/drawable/chat_private_128.xml create mode 100644 duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_settings.xml create mode 100644 duckchat/duckchat-impl/src/main/res/values/donottranslate.xml create mode 100644 duckchat/readme.md diff --git a/app/build.gradle b/app/build.gradle index 1e9dd64f0599..5643c0fdf74b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -215,6 +215,8 @@ fladle { } dependencies { + implementation project(":duckchat-api") + implementation project(":duckchat-impl") implementation project(":malicious-site-protection-impl") implementation project(":malicious-site-protection-api") implementation project(":custom-tabs-impl") 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 f74ef8f38970..f80f69f62237 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -261,6 +261,7 @@ import com.duckduckgo.downloads.api.DownloadConfirmationDialogListener 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.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams import com.duckduckgo.js.messaging.api.JsCallbackData @@ -518,6 +519,9 @@ class BrowserTabFragment : @Inject lateinit var duckPlayer: DuckPlayer + @Inject + lateinit var duckChat: DuckChat + @Inject lateinit var webViewCapabilityChecker: WebViewCapabilityChecker @@ -971,6 +975,9 @@ class BrowserTabFragment : onMenuItemClicked(newTabMenuItem) { onOmnibarNewTabRequested() } + onMenuItemClicked(duckChatMenuItem) { + duckChat.openDuckChat() + } onMenuItemClicked(bookmarksMenuItem) { browserActivity?.launchBookmarks() pixel.fire(AppPixelName.MENU_ACTION_BOOKMARKS_PRESSED.pixelName) @@ -3503,8 +3510,6 @@ class BrowserTabFragment : private const val AUTOCOMPLETE_PADDING_DP = 6 - private const val TOGGLE_REPORT_TOAST_DELAY = 3000L - fun newInstance( tabId: String, query: String? = null, 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 81d365dae0a9..7891486ebb0c 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -277,6 +277,7 @@ import com.duckduckgo.downloads.api.DownloadCommand import com.duckduckgo.downloads.api.DownloadStateListener import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload +import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.history.api.NavigationHistory @@ -424,6 +425,7 @@ class BrowserTabViewModel @Inject constructor( private val newTabPixels: Lazy, // Lazy to construct the instance and deps only when actually sending the pixel private val httpErrorPixels: Lazy, private val duckPlayer: DuckPlayer, + private val duckChat: DuckChat, private val duckPlayerJSHelper: DuckPlayerJSHelper, private val refreshPixelSender: RefreshPixelSender, private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature, @@ -811,6 +813,9 @@ class BrowserTabViewModel @Inject constructor( } viewModelScope.launch { + browserViewState.value = currentBrowserViewState().copy( + showDuckChatOption = duckChat.showInBrowserMenu(), + ) refreshOnViewVisible.emit(true) } } @@ -2417,11 +2422,13 @@ class BrowserTabViewModel @Inject constructor( withContext(dispatchers.io()) { val addToHomeSupported = addToHomeCapabilityDetector.isAddToHomeSupported() val showAutofill = autofillCapabilityChecker.canAccessCredentialManagementScreen() + val showDuckChat = duckChat.showInBrowserMenu() withContext(dispatchers.main()) { browserViewState.value = currentBrowserViewState().copy( addToHomeVisible = addToHomeSupported, showAutofill = showAutofill, + showDuckChatOption = showDuckChat, ) } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt b/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt index d62cf953ca03..10066bc98e85 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt @@ -94,6 +94,13 @@ class BrowserPopupMenu( } } + internal val duckChatMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.includeDuckChatMenuItem.duckChatMenuItem + BOTTOM -> bottomBinding.includeDuckChatMenuItem.duckChatMenuItem + } + } + internal val sharePageMenuItem: View by lazy { when (omnibarPosition) { TOP -> topBinding.sharePageMenuItem @@ -245,6 +252,7 @@ class BrowserPopupMenu( printPageMenuItem.isEnabled = browserShowing newTabMenuItem.isVisible = browserShowing && !displayedInCustomTabScreen + duckChatMenuItem.isVisible = viewState.showDuckChatOption && !displayedInCustomTabScreen sharePageMenuItem.isVisible = viewState.canSharePage defaultBrowserMenuItem.isVisible = viewState.showSelectDefaultBrowserMenuItem diff --git a/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt b/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt index afd8ea760b2f..94645833574b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt @@ -54,6 +54,7 @@ data class BrowserViewState( val browserError: WebViewErrorResponse = WebViewErrorResponse.OMITTED, val sslError: SSLErrorType = SSLErrorType.NONE, val privacyProtectionsPopupViewState: PrivacyProtectionsPopupViewState = PrivacyProtectionsPopupViewState.Gone, + val showDuckChatOption: Boolean = false, ) sealed class HighlightableButton { diff --git a/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt index 4ab3b36d825d..589683bc3433 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt @@ -50,6 +50,7 @@ import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAppearance import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAutofillSettings import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchCookiePopupProtectionScreen import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchDefaultBrowser +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchDuckChatScreen import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchEmailProtection import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchEmailProtectionNotSupported import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchFeedback @@ -77,6 +78,7 @@ import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams import com.duckduckgo.internal.features.api.InternalFeaturePlugin import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerActivityWithEmptyParams import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerOnboardingActivityWithEmptyParamsParams @@ -191,6 +193,7 @@ class NewSettingsActivity : DuckDuckGoActivity() { appearanceSetting.setClickListener { viewModel.onAppearanceSettingClicked() } accessibilitySetting.setClickListener { viewModel.onAccessibilitySettingClicked() } generalSetting.setClickListener { viewModel.onGeneralSettingClicked() } + duckChatSetting.setOnClickListener { viewModel.onDuckChatSettingClicked() } } with(viewsNextSteps) { @@ -254,6 +257,7 @@ class NewSettingsActivity : DuckDuckGoActivity() { updateAutoconsent(it.isAutoconsentEnabled) updatePrivacyPro(it.isPrivacyProEnabled) updateDuckPlayer(it.isDuckPlayerEnabled) + updateDuckChat(it.isDuckChatEnabled) updateVoiceSearchVisibility(it.isVoiceSearchVisible) } }.launchIn(lifecycleScope) @@ -281,6 +285,14 @@ class NewSettingsActivity : DuckDuckGoActivity() { } } + private fun updateDuckChat(isDuckChatEnabled: Boolean) { + if (isDuckChatEnabled) { + viewsMain.duckChatSetting.show() + } else { + viewsMain.duckChatSetting.gone() + } + } + private fun updateVoiceSearchVisibility(isVisible: Boolean) { viewsNextSteps.enableVoiceSearchSetting.isVisible = isVisible } @@ -323,6 +335,7 @@ class NewSettingsActivity : DuckDuckGoActivity() { is LaunchCookiePopupProtectionScreen -> launchActivity(AutoconsentSettingsActivity.intent(this)) is LaunchFireButtonScreen -> launchScreen(FireButtonScreenNoParams) is LaunchPermissionsScreen -> launchScreen(PermissionsScreenNoParams) + is LaunchDuckChatScreen -> launchScreen(DuckChatSettingsNoParams) is LaunchAppearanceScreen -> launchScreen(AppearanceScreen.Default) is LaunchAboutScreen -> launchScreen(AboutScreenNoParams) is LaunchGeneralSettingsScreen -> launchScreen(GeneralSettingsScreenNoParams) diff --git a/app/src/main/java/com/duckduckgo/app/settings/NewSettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsViewModel.kt index d80f651cd494..df0c6c7fd919 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/NewSettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsViewModel.kt @@ -50,6 +50,7 @@ import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAppearance import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchAutofillSettings import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchCookiePopupProtectionScreen import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchDefaultBrowser +import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchDuckChatScreen import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchEmailProtection import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchEmailProtectionNotSupported import com.duckduckgo.app.settings.NewSettingsViewModel.Command.LaunchFeedback @@ -68,6 +69,7 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED_WIH_HELP_LINK import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED @@ -101,6 +103,7 @@ class NewSettingsViewModel @Inject constructor( private val autoconsent: Autoconsent, private val subscriptions: Subscriptions, private val duckPlayer: DuckPlayer, + private val duckChat: DuckChat, private val voiceSearchAvailability: VoiceSearchAvailability, private val privacyProUnifiedFeedback: PrivacyProUnifiedFeedback, ) : ViewModel(), DefaultLifecycleObserver { @@ -115,6 +118,7 @@ class NewSettingsViewModel @Inject constructor( val isAutoconsentEnabled: Boolean = false, val isPrivacyProEnabled: Boolean = false, val isDuckPlayerEnabled: Boolean = false, + val isDuckChatEnabled: Boolean = false, val isVoiceSearchVisible: Boolean = false, ) @@ -133,6 +137,7 @@ class NewSettingsViewModel @Inject constructor( data object LaunchCookiePopupProtectionScreen : Command() data object LaunchFireButtonScreen : Command() data object LaunchPermissionsScreen : Command() + data object LaunchDuckChatScreen : Command() data object LaunchAppearanceScreen : Command() data object LaunchAboutScreen : Command() data object LaunchGeneralSettingsScreen : Command() @@ -177,6 +182,7 @@ class NewSettingsViewModel @Inject constructor( isAutoconsentEnabled = autoconsent.isSettingEnabled(), isPrivacyProEnabled = subscriptions.isEligible(), isDuckPlayerEnabled = duckPlayer.getDuckPlayerState().let { it == ENABLED || it == DISABLED_WIH_HELP_LINK }, + isDuckChatEnabled = duckChat.isEnabled(), isVoiceSearchVisible = voiceSearchAvailability.isVoiceSearchSupported, ), ) @@ -308,6 +314,10 @@ class NewSettingsViewModel @Inject constructor( pixel.fire(SETTINGS_PERMISSIONS_PRESSED) } + fun onDuckChatSettingClicked() { + viewModelScope.launch { command.send(LaunchDuckChatScreen) } + } + fun onAppearanceSettingClicked() { viewModelScope.launch { command.send(LaunchAppearanceScreen) } pixel.fire(SETTINGS_APPEARANCE_PRESSED) diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt index 9f421caa352c..13f8f4b0e470 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt @@ -61,6 +61,8 @@ import com.duckduckgo.common.ui.view.hide import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.api.DuckChat +import com.duckduckgo.navigation.api.GlobalActivityStarter import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import javax.inject.Inject @@ -69,6 +71,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking @InjectWith(ActivityScope::class) class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, CoroutineScope { @@ -113,6 +116,12 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine @Inject lateinit var appBuildConfig: AppBuildConfig + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + + @Inject + lateinit var duckChat: DuckChat + private val viewModel: TabSwitcherViewModel by bindViewModel() private val tabsAdapter: TabSwitcherAdapter by lazy { TabSwitcherAdapter(this, webViewPreviewPersister, this, faviconManager, dispatchers) } @@ -326,6 +335,7 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine R.id.fire -> onFire() R.id.newTab -> onNewTabRequested(fromOverflowMenu = false) R.id.newTabOverflow -> onNewTabRequested(fromOverflowMenu = true) + R.id.duckChat -> duckChat.openDuckChat() R.id.closeAllTabs -> closeAllTabs() R.id.downloads -> showDownloads() R.id.settings -> showSettings() @@ -341,6 +351,11 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine override fun onPrepareOptionsMenu(menu: Menu?): Boolean { val closeAllTabsMenuItem = menu?.findItem(R.id.closeAllTabs) closeAllTabsMenuItem?.isVisible = viewModel.tabs.value?.isNotEmpty() == true + val duckChatMenuItem = menu?.findItem(R.id.duckChat) + runBlocking { + duckChatMenuItem?.isVisible = duckChat.showInBrowserMenu() + } + return super.onPrepareOptionsMenu(menu) } diff --git a/app/src/main/res/drawable/ic_ai_chat_16.xml b/app/src/main/res/drawable/ic_ai_chat_16.xml new file mode 100644 index 000000000000..aa269b05b904 --- /dev/null +++ b/app/src/main/res/drawable/ic_ai_chat_16.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_new_pill.xml b/app/src/main/res/drawable/ic_new_pill.xml new file mode 100644 index 000000000000..ed1ce969f86b --- /dev/null +++ b/app/src/main/res/drawable/ic_new_pill.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/content_settings_main_settings.xml b/app/src/main/res/layout/content_settings_main_settings.xml index 8a1d00d410bb..34f5c7149b0c 100644 --- a/app/src/main/res/layout/content_settings_main_settings.xml +++ b/app/src/main/res/layout/content_settings_main_settings.xml @@ -59,6 +59,7 @@ app:primaryText="@string/settingsPasswordsAndAutofillLoginsSetting" app:leadingIcon="@drawable/ic_key_color_24"/> + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_tab_switcher_activity.xml b/app/src/main/res/menu/menu_tab_switcher_activity.xml index be65c54efb84..2bd21eebdfdf 100644 --- a/app/src/main/res/menu/menu_tab_switcher_activity.xml +++ b/app/src/main/res/menu/menu_tab_switcher_activity.xml @@ -43,6 +43,11 @@ android:title="@string/newTabMenuItem" app:showAsAction="never" /> + + Set As Your Default Browser Not Now + + Duck.ai + diff --git a/common/common-ui/src/main/res/drawable/ic_ai_chat_24.xml b/common/common-ui/src/main/res/drawable/ic_ai_chat_24.xml new file mode 100644 index 000000000000..2f755029f7c9 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_ai_chat_24.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + diff --git a/duckchat/duckchat-api/.gitignore b/duckchat/duckchat-api/.gitignore new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/duckchat/duckchat-api/build.gradle b/duckchat/duckchat-api/build.gradle new file mode 100644 index 000000000000..f4f37a9df4b7 --- /dev/null +++ b/duckchat/duckchat-api/build.gradle @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2021 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +android { + namespace "com.duckduckgo.duckchat.api" + compileOptions { + coreLibraryDesugaringEnabled = true + } +} + +dependencies { + implementation project(':navigation-api') + + implementation KotlinX.coroutines.core + implementation AndroidX.appCompat + + coreLibraryDesugaring Android.tools.desugarJdkLibs +} + + diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt new file mode 100644 index 000000000000..b9624cee1040 --- /dev/null +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 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.api + +/** + * DuckChat interface provides a set of methods for interacting and controlling DuckChat. + */ +interface DuckChat { + /** + * Checks whether DuckChat is enabled based on remote config flag. + * Sets IO dispatcher. + * + * @return true if DuckChat is enabled, false otherwise. + */ + suspend fun isEnabled(): Boolean + + /** + * Checks whether DuckChat should be shown in browser menu based on user settings. + * Sets IO dispatcher. + * + * @return true if DuckChat should be shown, false otherwise. + */ + suspend fun showInBrowserMenu(): Boolean + + /** + * Opens the DuckChat WebView. + */ + fun openDuckChat() +} diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatSettingsScreens.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatSettingsScreens.kt new file mode 100644 index 000000000000..8d6667acbcfe --- /dev/null +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatSettingsScreens.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 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.api + +import com.duckduckgo.navigation.api.GlobalActivityStarter + +/** + * Use this model to launch the Duck Player Settings screen + */ +object DuckChatSettingsNoParams : GlobalActivityStarter.ActivityParams diff --git a/duckchat/duckchat-impl/build.gradle b/duckchat/duckchat-impl/build.gradle new file mode 100644 index 000000000000..7757ffb8e314 --- /dev/null +++ b/duckchat/duckchat-impl/build.gradle @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2021 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.squareup.anvil' + id 'com.google.devtools.ksp' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +dependencies { + implementation project(":duckchat-api") + implementation project(':settings-api') + implementation project(':navigation-api') + implementation project(':common-ui') + implementation project(':common-utils') + implementation project(':browser-api') + + anvil project(path: ':anvil-compiler') + implementation project(path: ':anvil-annotations') + implementation project(path: ':di') + api AndroidX.dataStore.preferences + + ksp AndroidX.room.compiler + + implementation KotlinX.coroutines.android + implementation AndroidX.core.ktx + implementation Google.android.material + implementation Google.dagger + + implementation "com.squareup.logcat:logcat:_" + + testImplementation Testing.junit4 + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation project(path: ':common-test') + testImplementation CashApp.turbine + testImplementation Testing.robolectric + testImplementation(KotlinX.coroutines.test) { + // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + // conflicts with mockito due to direct inclusion of byte buddy + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + + coreLibraryDesugaring Android.tools.desugarJdkLibs +} + +android { + namespace "com.duckduckgo.duckchat.impl" + anvil { + generateDaggerFactories = true // default is false + } + lint { + baseline file("lint-baseline.xml") + } + testOptions { + unitTests { + includeAndroidResources = true + } + } + compileOptions { + coreLibraryDesugaringEnabled = true + } +} + diff --git a/duckchat/duckchat-impl/lint-baseline.xml b/duckchat/duckchat-impl/lint-baseline.xml new file mode 100644 index 000000000000..c584e1295716 --- /dev/null +++ b/duckchat/duckchat-impl/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/duckchat/duckchat-impl/src/main/AndroidManifest.xml b/duckchat/duckchat-impl/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..957398d9ad8c --- /dev/null +++ b/duckchat/duckchat-impl/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatDataStore.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatDataStore.kt new file mode 100644 index 000000000000..b87363b6993f --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatDataStore.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024 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 + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.impl.SharedPreferencesDuckChatDataStore.Keys.DUCK_CHAT_SHOW_IN_MENU +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +interface DuckChatDataStore { + suspend fun setShowInBrowserMenu(showDuckChat: Boolean) + fun observeShowInBrowserMenu(): Flow + fun getShowInBrowserMenu(): Boolean +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class SharedPreferencesDuckChatDataStore @Inject constructor( + @DuckChat private val store: DataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : DuckChatDataStore { + + private object Keys { + val DUCK_CHAT_SHOW_IN_MENU = booleanPreferencesKey(name = "DUCK_CHAT_SHOW_IN_MENU") + } + + private val duckChatShowInBrowserMenu: StateFlow = store.data + .map { prefs -> + prefs[DUCK_CHAT_SHOW_IN_MENU] ?: true + } + .distinctUntilChanged() + .stateIn(appCoroutineScope, SharingStarted.Eagerly, true) + + override suspend fun setShowInBrowserMenu(showDuckChat: Boolean) { + store.edit { prefs -> prefs[DUCK_CHAT_SHOW_IN_MENU] = showDuckChat } + } + + override fun observeShowInBrowserMenu(): Flow { + return duckChatShowInBrowserMenu + } + + override fun getShowInBrowserMenu(): Boolean { + return duckChatShowInBrowserMenu.value + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatDataStoreModule.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatDataStoreModule.kt new file mode 100644 index 000000000000..ed4816d2dcc0 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatDataStoreModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 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 + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import javax.inject.Qualifier + +@ContributesTo(AppScope::class) +@Module +object DuckChatDataStoreModule { + + private val Context.duckChatDataStore: DataStore by preferencesDataStore( + name = "duck_chat", + ) + + @Provides + @DuckChat + fun provideDuckChatDataStore(context: Context): DataStore = context.duckChatDataStore +} + +@Qualifier +internal annotation class DuckChat diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeature.kt new file mode 100644 index 000000000000..d7186155b378 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeature.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 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 + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "aiChat", +) +/** + * This is the class that represents the duckPlayer feature flags + */ +interface DuckChatFeature { + /** + * @return `true` when the remote config has the global "aiChat" feature flag enabled + * If the remote feature is not present defaults to `false` + */ + @Toggle.DefaultValue(false) + fun self(): Toggle +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeatureRepository.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeatureRepository.kt new file mode 100644 index 000000000000..f41d643c93ea --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeatureRepository.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 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 + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +interface DuckChatFeatureRepository { + suspend fun setShowInBrowserMenu(showDuckChat: Boolean) + fun observeShowInBrowserMenu(): Flow + fun shouldShowInBrowserMenu(): Boolean +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealDuckChatFeatureRepository @Inject constructor( + private val duckChatDataStore: DuckChatDataStore, +) : DuckChatFeatureRepository { + + override suspend fun setShowInBrowserMenu(showDuckChat: Boolean) { + duckChatDataStore.setShowInBrowserMenu(showDuckChat) + } + + override fun observeShowInBrowserMenu(): Flow { + return duckChatDataStore.observeShowInBrowserMenu() + } + + override fun shouldShowInBrowserMenu(): Boolean { + return duckChatDataStore.getShowInBrowserMenu() + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsActivity.kt new file mode 100644 index 000000000000..882df702db00 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsActivity.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2024 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 + +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.view.addClickableLink +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams +import com.duckduckgo.duckchat.impl.databinding.ActivityDuckChatSettingsBinding +import com.duckduckgo.navigation.api.GlobalActivityStarter +import javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(DuckChatSettingsNoParams::class) +class DuckChatSettingsActivity : DuckDuckGoActivity() { + + private val viewModel: DuckChatSettingsViewModel by bindViewModel() + private val binding: ActivityDuckChatSettingsBinding by viewBinding() + + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + + binding.duckChatSettingsText.addClickableLink( + annotation = "learn_more_link", + textSequence = getText(R.string.duck_chat_settings_activity_description), + onClick = { viewModel.duckChatLearnMoreClicked() }, + ) + + setupToolbar(binding.includeToolbar.toolbar) + + configureUiEventHandlers() + observeViewModel() + } + + private fun configureUiEventHandlers() { + binding.showDuckChatInMenuToggle.setOnCheckedChangeListener { _, isChecked -> + viewModel.onShowDuckChatInMenuToggled(isChecked) + } + } + + private fun observeViewModel() { + viewModel.viewState + .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { renderViewState(it.showInBrowserMenu) } + .launchIn(lifecycleScope) + + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun renderViewState(showInBrowserMenu: Boolean) { + binding.showDuckChatInMenuToggle.setIsChecked(showInBrowserMenu) + } + + private fun processCommand(command: DuckChatSettingsViewModel.Command) { + when (command) { + is DuckChatSettingsViewModel.Command.OpenLearnMore -> { + globalActivityStarter.start( + this, + WebViewActivityWithParams( + url = command.learnMoreLink, + screenTitle = getString(R.string.duck_chat_title), + ), + ) + } + } + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsViewModel.kt new file mode 100644 index 000000000000..79445b537a6a --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 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 + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.common.ui.notifyme.NotifyMeViewModel.Command +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.impl.DuckChatSettingsViewModel.Command.OpenLearnMore +import javax.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@ContributesViewModel(ActivityScope::class) +class DuckChatSettingsViewModel @Inject constructor( + private val duckChat: DuckChatInternal, +) : ViewModel() { + + private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) + val commands = commandChannel.receiveAsFlow() + + data class ViewState( + val showInBrowserMenu: Boolean = false, + ) + + val viewState = duckChat.observeShowInBrowserMenu() + .map { showInBrowserMenu -> + ViewState(showInBrowserMenu) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ViewState()) + + sealed class Command { + data class OpenLearnMore(val learnMoreLink: String) : Command() + } + + fun onShowDuckChatInMenuToggled(checked: Boolean) { + viewModelScope.launch { + duckChat.setShowInBrowserMenu(checked) + } + } + + fun duckChatLearnMoreClicked() { + viewModelScope.launch { + commandChannel.send(OpenLearnMore("https://duckduckgo.com/duckduckgo-help-pages/aichat/")) + } + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt new file mode 100644 index 000000000000..724e58ada677 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2024 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 + +import android.content.Context +import android.content.Intent +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckChat +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +interface DuckChatInternal : DuckChat { + /** + * Stores setting to determine whether the DuckChat should be shown in browser menu. + * Sets IO dispatcher. + */ + suspend fun setShowInBrowserMenu(showDuckChat: Boolean) + + /** + * Observes whether DuckChat should be shown in browser menu based on user settings and remote config flag + */ + fun observeShowInBrowserMenu(): Flow +} + +data class DuckChatSettingJson( + val aiChatURL: String, +) + +@ContributesBinding(AppScope::class, boundType = DuckChat::class) +@ContributesBinding(AppScope::class, boundType = DuckChatInternal::class) +class RealDuckChat @Inject constructor( + private val duckChatFeatureRepository: DuckChatFeatureRepository, + private val duckChatFeature: DuckChatFeature, + private val moshi: Moshi, + private val dispatchers: DispatcherProvider, + private val globalActivityStarter: GlobalActivityStarter, + private val context: Context, +) : DuckChatInternal { + + private val jsonAdapter: JsonAdapter by lazy { + moshi.adapter(DuckChatSettingJson::class.java) + } + + override suspend fun isEnabled(): Boolean = withContext(dispatchers.io()) { + duckChatFeature.self().isEnabled() + } + + override suspend fun setShowInBrowserMenu(showDuckChat: Boolean) = withContext(dispatchers.io()) { + duckChatFeatureRepository.setShowInBrowserMenu(showDuckChat) + } + + override fun observeShowInBrowserMenu(): Flow { + return duckChatFeatureRepository.observeShowInBrowserMenu().map { + it && duckChatFeature.self().isEnabled() + } + } + + override suspend fun showInBrowserMenu(): Boolean = withContext(dispatchers.io()) { + duckChatFeatureRepository.shouldShowInBrowserMenu() && duckChatFeature.self().isEnabled() + } + + override fun openDuckChat() { + val link = duckChatFeature.self().getSettings()?.let { + runCatching { + val settingsJson = jsonAdapter.fromJson(it) + settingsJson?.aiChatURL + }.getOrDefault(DUCK_CHAT_WEB_LINK) + } ?: DUCK_CHAT_WEB_LINK + + val intent = globalActivityStarter.startIntent( + context, + WebViewActivityWithParams( + url = link, + screenTitle = context.getString(R.string.duck_chat_title), + ), + ) + + intent?.let { + it.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(it) + } + } + + companion object { + /** Default link to DuckChat that identifies Android as the source */ + private const val DUCK_CHAT_WEB_LINK = "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5" + } +} diff --git a/duckchat/duckchat-impl/src/main/res/drawable/chat_private_128.xml b/duckchat/duckchat-impl/src/main/res/drawable/chat_private_128.xml new file mode 100644 index 000000000000..e36f46e4c076 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/drawable/chat_private_128.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_settings.xml b/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_settings.xml new file mode 100644 index 000000000000..73e4e29f2a1e --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_settings.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + \ 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..e8251ee4bb20 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -0,0 +1,20 @@ + + + Duck.ai + AI Chat is an optional feature available at duck.ai that lets you have private conversations with popular 3rd-party AI chat models. Your chats are not used to train chat models.\nLearn More + Show Duck.ai in Browser Menu + \ No newline at end of file diff --git a/duckchat/readme.md b/duckchat/readme.md new file mode 100644 index 000000000000..5e1918881d21 --- /dev/null +++ b/duckchat/readme.md @@ -0,0 +1,11 @@ +# DuckChat + +In-browser shortcuts and settings for DuckChat. + +## Who can help you better understand this feature? +- Nastia Shuba +- Josh Leibstein + +## More information +- [Duck Chat entry points project](https://app.asana.com/0/1205782359654716/1207664840517841/f) +- [Asana: feature documentation](❓) From ddccfdee7b25be583185932b22e7a3a91fda0242 Mon Sep 17 00:00:00 2001 From: Anastasia Shuba Date: Wed, 8 Jan 2025 11:01:29 -0800 Subject: [PATCH 02/16] latest design --- .../app/settings/NewSettingsActivity.kt | 6 +- app/src/main/res/drawable/ic_ai_chat_24.xml | 26 +++++++ .../layout/content_settings_main_settings.xml | 10 +-- .../res/layout/popup_window_browser_menu.xml | 2 +- .../popup_window_browser_menu_bottom.xml | 2 +- ...ature.xml => view_menu_item_duck_chat.xml} | 4 +- .../layout/view_settings_item_duck_chat.xml | 72 +++++++++++++++++++ .../src/main/res/drawable/ic_ai_chat_24.xml | 35 --------- 8 files changed, 108 insertions(+), 49 deletions(-) create mode 100644 app/src/main/res/drawable/ic_ai_chat_24.xml rename app/src/main/res/layout/{view_menu_item_new_feature.xml => view_menu_item_duck_chat.xml} (94%) create mode 100644 app/src/main/res/layout/view_settings_item_duck_chat.xml delete mode 100644 common/common-ui/src/main/res/drawable/ic_ai_chat_24.xml diff --git a/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt index 589683bc3433..17fbc41007f0 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsActivity.kt @@ -193,7 +193,7 @@ class NewSettingsActivity : DuckDuckGoActivity() { appearanceSetting.setClickListener { viewModel.onAppearanceSettingClicked() } accessibilitySetting.setClickListener { viewModel.onAccessibilitySettingClicked() } generalSetting.setClickListener { viewModel.onGeneralSettingClicked() } - duckChatSetting.setOnClickListener { viewModel.onDuckChatSettingClicked() } + includeDuckChatSetting.duckChatSetting.setOnClickListener { viewModel.onDuckChatSettingClicked() } } with(viewsNextSteps) { @@ -287,9 +287,9 @@ class NewSettingsActivity : DuckDuckGoActivity() { private fun updateDuckChat(isDuckChatEnabled: Boolean) { if (isDuckChatEnabled) { - viewsMain.duckChatSetting.show() + viewsMain.includeDuckChatSetting.duckChatSetting.show() } else { - viewsMain.duckChatSetting.gone() + viewsMain.includeDuckChatSetting.duckChatSetting.gone() } } diff --git a/app/src/main/res/drawable/ic_ai_chat_24.xml b/app/src/main/res/drawable/ic_ai_chat_24.xml new file mode 100644 index 000000000000..616720bbc0e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_ai_chat_24.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/content_settings_main_settings.xml b/app/src/main/res/layout/content_settings_main_settings.xml index 34f5c7149b0c..30805f3f7825 100644 --- a/app/src/main/res/layout/content_settings_main_settings.xml +++ b/app/src/main/res/layout/content_settings_main_settings.xml @@ -59,7 +59,6 @@ app:primaryText="@string/settingsPasswordsAndAutofillLoginsSetting" app:leadingIcon="@drawable/ic_key_color_24"/> - - + + layout="@layout/view_menu_item_duck_chat" /> + layout="@layout/view_menu_item_duck_chat" /> diff --git a/app/src/main/res/layout/view_settings_item_duck_chat.xml b/app/src/main/res/layout/view_settings_item_duck_chat.xml new file mode 100644 index 000000000000..e07da939515e --- /dev/null +++ b/app/src/main/res/layout/view_settings_item_duck_chat.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/common-ui/src/main/res/drawable/ic_ai_chat_24.xml b/common/common-ui/src/main/res/drawable/ic_ai_chat_24.xml deleted file mode 100644 index 2f755029f7c9..000000000000 --- a/common/common-ui/src/main/res/drawable/ic_ai_chat_24.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - From b880a20c20fda464aea946f443aecf521fc9f9c9 Mon Sep 17 00:00:00 2001 From: Anastasia Shuba Date: Wed, 8 Jan 2025 16:17:37 -0800 Subject: [PATCH 03/16] avoid runBlocking + fix openDuckChat --- .../app/browser/BrowserTabFragment.kt | 6 +---- .../app/browser/BrowserTabViewModel.kt | 23 +++++++++++++---- .../app/tabs/ui/TabSwitcherActivity.kt | 23 +++++++++-------- .../app/tabs/ui/TabSwitcherViewModel.kt | 9 +++++++ .../com/duckduckgo/duckchat/api/DuckChat.kt | 13 +++++----- .../duckduckgo/duckchat/impl/RealDuckChat.kt | 25 +++++++------------ 6 files changed, 55 insertions(+), 44 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 f80f69f62237..fe5f9ecf84da 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -261,7 +261,6 @@ import com.duckduckgo.downloads.api.DownloadConfirmationDialogListener 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.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams import com.duckduckgo.js.messaging.api.JsCallbackData @@ -519,9 +518,6 @@ class BrowserTabFragment : @Inject lateinit var duckPlayer: DuckPlayer - @Inject - lateinit var duckChat: DuckChat - @Inject lateinit var webViewCapabilityChecker: WebViewCapabilityChecker @@ -976,7 +972,7 @@ class BrowserTabFragment : onOmnibarNewTabRequested() } onMenuItemClicked(duckChatMenuItem) { - duckChat.openDuckChat() + viewModel.onDuckChatMenuItemClicked() } onMenuItemClicked(bookmarksMenuItem) { browserActivity?.launchBookmarks() 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 7891486ebb0c..58c5559ab703 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -503,6 +503,12 @@ class BrowserTabViewModel @Inject constructor( internal val autoCompleteStateFlow = MutableStateFlow("") private val fireproofWebsiteState: LiveData> = fireproofWebsiteRepository.getFireproofWebsites() + @ExperimentalCoroutinesApi + @FlowPreview + private val duckChatVisibility = duckChat.observeShowInBrowserMenu().asLiveData( + context = viewModelScope.coroutineContext, + ) + @ExperimentalCoroutinesApi @FlowPreview private val showPulseAnimation: LiveData = ctaViewModel.showFireButtonPulseAnimation.asLiveData( @@ -545,6 +551,10 @@ class BrowserTabViewModel @Inject constructor( } } + private val duckChatVisibilityObserver = Observer { + browserViewState.value = currentBrowserViewState().copy(showDuckChatOption = it) + } + private fun registerAndScheduleDismissAction() { viewModelScope.launch(dispatchers.io()) { val fireButtonHighlightedEvent = userEventsStore.getUserEvent(UserEventKey.FIRE_BUTTON_HIGHLIGHTED) @@ -595,6 +605,7 @@ class BrowserTabViewModel @Inject constructor( fireproofDialogsEventHandler.event.observeForever(fireproofDialogEventObserver) navigationAwareLoginDetector.loginEventLiveData.observeForever(loginDetectionObserver) showPulseAnimation.observeForever(fireButtonAnimation) + duckChatVisibility.observeForever(duckChatVisibilityObserver) tabRepository.childClosedTabs.onEach { closedTab -> if (this@BrowserTabViewModel::tabId.isInitialized && tabId == closedTab) { @@ -782,6 +793,7 @@ class BrowserTabViewModel @Inject constructor( navigationAwareLoginDetector.loginEventLiveData.removeObserver(loginDetectionObserver) fireproofDialogsEventHandler.event.removeObserver(fireproofDialogEventObserver) showPulseAnimation.removeObserver(fireButtonAnimation) + duckChatVisibility.removeObserver(duckChatVisibilityObserver) super.onCleared() } @@ -813,9 +825,6 @@ class BrowserTabViewModel @Inject constructor( } viewModelScope.launch { - browserViewState.value = currentBrowserViewState().copy( - showDuckChatOption = duckChat.showInBrowserMenu(), - ) refreshOnViewVisible.emit(true) } } @@ -2422,13 +2431,11 @@ class BrowserTabViewModel @Inject constructor( withContext(dispatchers.io()) { val addToHomeSupported = addToHomeCapabilityDetector.isAddToHomeSupported() val showAutofill = autofillCapabilityChecker.canAccessCredentialManagementScreen() - val showDuckChat = duckChat.showInBrowserMenu() withContext(dispatchers.main()) { browserViewState.value = currentBrowserViewState().copy( addToHomeVisible = addToHomeSupported, showAutofill = showAutofill, - showDuckChatOption = showDuckChat, ) } } @@ -2559,6 +2566,12 @@ class BrowserTabViewModel @Inject constructor( onUserDismissedCta(ctaViewState.value?.cta) } + fun onDuckChatMenuItemClicked() { + viewModelScope.launch { + duckChat.openDuckChat() + } + } + fun onCtaShown() { val cta = ctaViewState.value?.cta ?: return viewModelScope.launch(dispatchers.io()) { diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt index 13f8f4b0e470..2751afe81436 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt @@ -61,7 +61,6 @@ import com.duckduckgo.common.ui.view.hide import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.navigation.api.GlobalActivityStarter import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar @@ -71,7 +70,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking @InjectWith(ActivityScope::class) class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, CoroutineScope { @@ -119,9 +117,6 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine @Inject lateinit var globalActivityStarter: GlobalActivityStarter - @Inject - lateinit var duckChat: DuckChat - private val viewModel: TabSwitcherViewModel by bindViewModel() private val tabsAdapter: TabSwitcherAdapter by lazy { TabSwitcherAdapter(this, webViewPreviewPersister, this, faviconManager, dispatchers) } @@ -326,6 +321,11 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine null -> layoutTypeMenuItem?.isVisible = false } + viewModel.duckChatVisibility.observe(this) { + val duckChatMenuItem = menu.findItem(R.id.duckChat) + duckChatMenuItem?.isVisible = it + } + return true } @@ -335,7 +335,7 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine R.id.fire -> onFire() R.id.newTab -> onNewTabRequested(fromOverflowMenu = false) R.id.newTabOverflow -> onNewTabRequested(fromOverflowMenu = true) - R.id.duckChat -> duckChat.openDuckChat() + R.id.duckChat -> onDuckChatMenuItemClicked() R.id.closeAllTabs -> closeAllTabs() R.id.downloads -> showDownloads() R.id.settings -> showSettings() @@ -351,11 +351,6 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine override fun onPrepareOptionsMenu(menu: Menu?): Boolean { val closeAllTabsMenuItem = menu?.findItem(R.id.closeAllTabs) closeAllTabsMenuItem?.isVisible = viewModel.tabs.value?.isNotEmpty() == true - val duckChatMenuItem = menu?.findItem(R.id.duckChat) - runBlocking { - duckChatMenuItem?.isVisible = duckChat.showInBrowserMenu() - } - return super.onPrepareOptionsMenu(menu) } @@ -391,6 +386,10 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine launch { viewModel.onNewTabRequested(fromOverflowMenu) } } + fun onDuckChatMenuItemClicked() { + launch { viewModel.onDuckChatMenuItemClicked() } + } + override fun onTabSelected(tab: TabEntity) { selectedTabId = tab.tabId updateTabGridItemDecorator(tab) @@ -485,6 +484,7 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine override fun onDestroy() { super.onDestroy() viewModel.deletableTabs.removeObservers(this) + viewModel.duckChatVisibility.removeObservers(this) // we don't want to purge during device rotation if (isFinishing) { launch { viewModel.purgeDeletableTabs() } @@ -494,6 +494,7 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine private fun clearObserversEarlyToStopViewUpdates() { viewModel.tabs.removeObservers(this) viewModel.deletableTabs.removeObservers(this) + viewModel.duckChatVisibility.removeObservers(this) } private fun showCloseAllTabsConfirmation() { diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt index 6121400e4375..d1afd3b34830 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt @@ -33,6 +33,7 @@ import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType.LIST import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.SingleLiveEvent import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.api.DuckChat import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first @@ -47,12 +48,16 @@ class TabSwitcherViewModel @Inject constructor( private val adClickManager: AdClickManager, private val dispatcherProvider: DispatcherProvider, private val pixel: Pixel, + private val duckChat: DuckChat, ) : ViewModel() { val tabs: LiveData> = tabRepository.liveTabs val activeTab = tabRepository.liveSelectedTab val deletableTabs: LiveData> = tabRepository.flowDeletableTabs.asLiveData( context = viewModelScope.coroutineContext, ) + val duckChatVisibility = duckChat.observeShowInBrowserMenu().asLiveData( + context = viewModelScope.coroutineContext, + ) val layoutType = tabRepository.tabSwitcherData .map { it.layoutType } @@ -75,6 +80,10 @@ class TabSwitcherViewModel @Inject constructor( } } + suspend fun onDuckChatMenuItemClicked() { + duckChat.openDuckChat() + } + suspend fun onTabSelected(tab: TabEntity) { tabRepository.select(tab.tabId) command.value = Command.Close diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt index b9624cee1040..b6121623e38b 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt @@ -16,6 +16,8 @@ package com.duckduckgo.duckchat.api +import kotlinx.coroutines.flow.Flow + /** * DuckChat interface provides a set of methods for interacting and controlling DuckChat. */ @@ -29,15 +31,12 @@ interface DuckChat { suspend fun isEnabled(): Boolean /** - * Checks whether DuckChat should be shown in browser menu based on user settings. - * Sets IO dispatcher. - * - * @return true if DuckChat should be shown, false otherwise. + * Observes whether DuckChat should be shown in browser menu based on user settings and remote config flag */ - suspend fun showInBrowserMenu(): Boolean + fun observeShowInBrowserMenu(): Flow /** - * Opens the DuckChat WebView. + * Opens the DuckChat WebView. Sets IO dispatcher for disk operations. */ - fun openDuckChat() + suspend fun openDuckChat() } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index 724e58ada677..d31be914a839 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -37,11 +37,6 @@ interface DuckChatInternal : DuckChat { * Sets IO dispatcher. */ suspend fun setShowInBrowserMenu(showDuckChat: Boolean) - - /** - * Observes whether DuckChat should be shown in browser menu based on user settings and remote config flag - */ - fun observeShowInBrowserMenu(): Flow } data class DuckChatSettingJson( @@ -77,17 +72,15 @@ class RealDuckChat @Inject constructor( } } - override suspend fun showInBrowserMenu(): Boolean = withContext(dispatchers.io()) { - duckChatFeatureRepository.shouldShowInBrowserMenu() && duckChatFeature.self().isEnabled() - } - - override fun openDuckChat() { - val link = duckChatFeature.self().getSettings()?.let { - runCatching { - val settingsJson = jsonAdapter.fromJson(it) - settingsJson?.aiChatURL - }.getOrDefault(DUCK_CHAT_WEB_LINK) - } ?: DUCK_CHAT_WEB_LINK + override suspend fun openDuckChat() { + val link = withContext(dispatchers.io()) { + duckChatFeature.self().getSettings()?.let { + runCatching { + val settingsJson = jsonAdapter.fromJson(it) + settingsJson?.aiChatURL + }.getOrDefault(DUCK_CHAT_WEB_LINK) + } ?: DUCK_CHAT_WEB_LINK + } val intent = globalActivityStarter.startIntent( context, From 3876982c73a6ade60eee5ef79b103945e67d0590 Mon Sep 17 00:00:00 2001 From: Anastasia Shuba Date: Wed, 8 Jan 2025 19:27:00 -0800 Subject: [PATCH 04/16] code review from Josh --- app/src/main/res/drawable/ic_ai_chat_16.xml | 2 +- .../main/res/layout/view_menu_item_duck_chat.xml | 16 +++++++++++++++- duckchat/duckchat-api/build.gradle | 2 +- .../java/com/duckduckgo/duckchat/api/DuckChat.kt | 2 +- .../duckchat/api/DuckChatSettingsScreens.kt | 2 +- duckchat/duckchat-impl/build.gradle | 2 +- .../duckchat-impl/src/main/AndroidManifest.xml | 2 +- .../duckchat/impl/DuckChatDataStore.kt | 2 +- .../duckchat/impl/DuckChatDataStoreModule.kt | 2 +- .../duckduckgo/duckchat/impl/DuckChatFeature.kt | 4 ++-- .../duckchat/impl/DuckChatFeatureRepository.kt | 2 +- .../duckchat/impl/DuckChatSettingsActivity.kt | 2 +- .../duckchat/impl/DuckChatSettingsViewModel.kt | 2 +- .../com/duckduckgo/duckchat/impl/RealDuckChat.kt | 4 ++-- .../res/layout/activity_duck_chat_settings.xml | 2 +- .../src/main/res/values/donottranslate.xml | 2 +- 16 files changed, 32 insertions(+), 18 deletions(-) diff --git a/app/src/main/res/drawable/ic_ai_chat_16.xml b/app/src/main/res/drawable/ic_ai_chat_16.xml index aa269b05b904..6f4f2fc8842a 100644 --- a/app/src/main/res/drawable/ic_ai_chat_16.xml +++ b/app/src/main/res/drawable/ic_ai_chat_16.xml @@ -1,5 +1,5 @@ Duck.ai + New AI Chat diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml index c367e8d94b1a..559e2abc553e 100644 --- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -15,6 +15,6 @@ --> Duck.ai - AI Chat is an optional feature available at duck.ai that lets you have private conversations with popular 3rd-party AI chat models. Your chats are not used to train chat models.\nLearn More + Duck.ai is an optional feature that lets you chat anonymously with popular 3rd-party Al chat models. Your chats are not used to train AI.\nLearn More Show Duck.ai in Browser Menu \ No newline at end of file From 8a8fcc1846ed93bd08ebce4904e46efb7a52ab71 Mon Sep 17 00:00:00 2001 From: Anastasia Shuba Date: Thu, 16 Jan 2025 16:18:26 -0800 Subject: [PATCH 14/16] copy request --- app/src/main/res/layout/content_settings_new_other.xml | 2 +- app/src/main/res/values/donottranslate.xml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/content_settings_new_other.xml b/app/src/main/res/layout/content_settings_new_other.xml index d9fafa0ef74c..26cb6104e99f 100644 --- a/app/src/main/res/layout/content_settings_new_other.xml +++ b/app/src/main/res/layout/content_settings_new_other.xml @@ -37,7 +37,7 @@ android:id="@+id/shareFeedbackSetting" android:layout_width="match_parent" android:layout_height="wrap_content" - app:primaryText="@string/leaveFeedback" + app:primaryText="@string/sendFeedback" app:leadingIcon="@drawable/ic_heart_gray_color_24" /> Duck.ai New AI Chat - + + Send Feedback From 62b4990e35001b7e1359489b30a3ffc7f653f044 Mon Sep 17 00:00:00 2001 From: Anastasia Shuba Date: Fri, 17 Jan 2025 11:43:29 -0800 Subject: [PATCH 15/16] code review --- .../app/browser/BrowserTabViewModelTest.kt | 18 ++++++ .../app/tabs/ui/TabSwitcherActivity.kt | 4 -- .../res/layout/content_settings_new_other.xml | 2 +- app/src/main/res/values/donottranslate.xml | 3 +- .../impl/DuckChatFeatureRepositoryTest.kt | 57 +++++++++++++++++++ 5 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/DuckChatFeatureRepositoryTest.kt 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 cd0a21ab41e5..a5e524aa192a 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -777,6 +777,22 @@ class BrowserTabViewModelTest { } } + @Test + fun whenViewBecomesVisibleAndDuckChatDisabledThenDuckChatNotVisible() { + whenever(mockDuckChat.showInBrowserMenu()).thenReturn(false) + + testee.onViewVisible() + assertFalse(browserViewState().showDuckChatOption) + } + + @Test + fun whenViewBecomesVisibleAndDuckChatEnabledThenDuckChatIsVisible() { + whenever(mockDuckChat.showInBrowserMenu()).thenReturn(true) + + testee.onViewVisible() + assertTrue(browserViewState().showDuckChatOption) + } + @Test fun whenInvalidatedGlobalLayoutRestoredThenErrorIsShown() { givenInvalidatedGlobalLayout() @@ -5240,6 +5256,8 @@ class BrowserTabViewModelTest { assertFalse(testee.isPrinting()) } + // TODO: + @Test fun whenOnFavoriteAddedThePixelFired() { testee.onFavoriteAdded() diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt index 6763299d8838..72b42a5f2673 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt @@ -62,7 +62,6 @@ import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.duckchat.api.DuckChat -import com.duckduckgo.navigation.api.GlobalActivityStarter import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import javax.inject.Inject @@ -115,9 +114,6 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine @Inject lateinit var appBuildConfig: AppBuildConfig - @Inject - lateinit var globalActivityStarter: GlobalActivityStarter - @Inject lateinit var duckChat: DuckChat diff --git a/app/src/main/res/layout/content_settings_new_other.xml b/app/src/main/res/layout/content_settings_new_other.xml index 26cb6104e99f..d9fafa0ef74c 100644 --- a/app/src/main/res/layout/content_settings_new_other.xml +++ b/app/src/main/res/layout/content_settings_new_other.xml @@ -37,7 +37,7 @@ android:id="@+id/shareFeedbackSetting" android:layout_width="match_parent" android:layout_height="wrap_content" - app:primaryText="@string/sendFeedback" + app:primaryText="@string/leaveFeedback" app:leadingIcon="@drawable/ic_heart_gray_color_24" /> Duck.ai New AI Chat - - Send Feedback + diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/DuckChatFeatureRepositoryTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/DuckChatFeatureRepositoryTest.kt new file mode 100644 index 000000000000..298ec3655491 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/DuckChatFeatureRepositoryTest.kt @@ -0,0 +1,57 @@ +/* + * 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 + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class DuckChatFeatureRepositoryTest { + private val mockDatabase: DuckChatDataStore = mock() + + private val testee = RealDuckChatFeatureRepository(mockDatabase) + + @Test + fun whenSetShowInBrowserMenuThenSetInDatabase() = runTest { + testee.setShowInBrowserMenu(true) + + verify(mockDatabase).setShowInBrowserMenu(true) + } + + @Test + fun whenObserveShowInBrowserMenuThenObserveDatabase() = runTest { + whenever(mockDatabase.observeShowInBrowserMenu()).thenReturn(flowOf(true, false)) + + val results = testee.observeShowInBrowserMenu().take(2).toList() + assertTrue(results[0]) + assertFalse(results[1]) + } + + @Test + fun whenShouldShowInBrowserMenuThenGetFromDatabase() { + whenever(mockDatabase.getShowInBrowserMenu()).thenReturn(true) + + assertTrue(testee.shouldShowInBrowserMenu()) + } +} From 95cf6e711dcaba5511cae64e96cc25b4e5adad5c Mon Sep 17 00:00:00 2001 From: Anastasia Shuba Date: Fri, 17 Jan 2025 16:52:53 -0800 Subject: [PATCH 16/16] fix test --- .../com/duckduckgo/app/browser/BrowserTabViewModelTest.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 a5e524aa192a..bb7a9dc2659d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -780,7 +780,7 @@ class BrowserTabViewModelTest { @Test fun whenViewBecomesVisibleAndDuckChatDisabledThenDuckChatNotVisible() { whenever(mockDuckChat.showInBrowserMenu()).thenReturn(false) - + setBrowserShowing(true) testee.onViewVisible() assertFalse(browserViewState().showDuckChatOption) } @@ -788,7 +788,7 @@ class BrowserTabViewModelTest { @Test fun whenViewBecomesVisibleAndDuckChatEnabledThenDuckChatIsVisible() { whenever(mockDuckChat.showInBrowserMenu()).thenReturn(true) - + setBrowserShowing(true) testee.onViewVisible() assertTrue(browserViewState().showDuckChatOption) } @@ -5256,8 +5256,6 @@ class BrowserTabViewModelTest { assertFalse(testee.isPrinting()) } - // TODO: - @Test fun whenOnFavoriteAddedThePixelFired() { testee.onFavoriteAdded()