diff --git a/app/build.gradle b/app/build.gradle index 6dfdeba02996..e9c275ed4e8c 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 6bd769638a08..ea7dcfd534b2 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -226,6 +226,7 @@ import com.duckduckgo.autofill.api.emailprotection.EmailInjector import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.RELOAD_THREE_TIMES_WITHIN_20_SECONDS +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.store.BrowserAppTheme import com.duckduckgo.common.ui.view.DaxDialog @@ -262,6 +263,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 @@ -1012,6 +1014,9 @@ class BrowserTabFragment : onMenuItemClicked(newTabMenuItem) { onOmnibarNewTabRequested() } + onMenuItemClicked(duckChatMenuItem) { + launchDuckChat() + } onMenuItemClicked(bookmarksMenuItem) { browserActivity?.launchBookmarks() pixel.fire(AppPixelName.MENU_ACTION_BOOKMARKS_PRESSED.pixelName) @@ -1140,6 +1145,13 @@ class BrowserTabFragment : startActivity(TabSwitcherActivity.intent(activity, tabId)) } + private fun launchDuckChat() { + globalActivityStarter.start(requireContext(), WebViewActivityWithParams( + url = DuckChat.DUCK_CHAT_WEB_LINK, + screenTitle = getString(string.duckChatScreenTitle), + )) + } + override fun onResume() { super.onResume() @@ -3561,8 +3573,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 9a95f9fe31fd..6b9243f05ebc 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 @@ -428,6 +429,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, @@ -826,6 +828,7 @@ class BrowserTabViewModel @Inject constructor( hasQueryChanged = false, urlLoaded = url ?: "", ), + showDuckChatOption = duckChat.showInBrowserMenu(), ) viewModelScope.launch { refreshOnViewVisible.emit(true) @@ -2513,12 +2516,14 @@ class BrowserTabViewModel @Inject constructor( val addToHomeSupported = addToHomeCapabilityDetector.isAddToHomeSupported() val showAutofill = autofillCapabilityChecker.canAccessCredentialManagementScreen() val showVoiceSearch = voiceSearchAvailability.shouldShowVoiceSearch() + val showDuckChat = duckChat.showInBrowserMenu() withContext(dispatchers.main()) { browserViewState.value = currentBrowserViewState().copy( addToHomeVisible = addToHomeSupported, showAutofill = showAutofill, showVoiceSearch = showVoiceSearch, + 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..4ddcc3b8f49b 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.duckChatMenuItem + BOTTOM -> bottomBinding.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 bfb487f1e751..548995084b6b 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 @@ -60,6 +60,7 @@ data class BrowserViewState( val sslError: SSLErrorType = SSLErrorType.NONE, val privacyProtectionsPopupViewState: PrivacyProtectionsPopupViewState = PrivacyProtectionsPopupViewState.Gone, val showDuckPlayerIcon: Boolean = false, + 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 acded73d4e11..0f484721f1a0 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 @@ -76,6 +77,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 @@ -190,6 +192,7 @@ class NewSettingsActivity : DuckDuckGoActivity() { appearanceSetting.setClickListener { viewModel.onAppearanceSettingClicked() } accessibilitySetting.setClickListener { viewModel.onAccessibilitySettingClicked() } generalSetting.setClickListener { viewModel.onGeneralSettingClicked() } + duckChatSetting.setOnClickListener { viewModel.onDuckChatSettingClicked() } } with(viewsNextSteps) { @@ -321,6 +324,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 a10526ce6a78..fb842e71058d 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/NewSettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/NewSettingsViewModel.kt @@ -48,6 +48,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 @@ -131,6 +132,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() @@ -304,6 +306,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 4b2529dbd8c5..c1ffcce18834 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 @@ -52,6 +52,7 @@ import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.Command import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.Command.Close import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.Command.CloseAllTabsRequest import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.view.button.ButtonType.DESTRUCTIVE import com.duckduckgo.common.ui.view.button.ButtonType.GHOST_ALT @@ -61,6 +62,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 @@ -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) } @@ -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 -> launchDuckChat() R.id.closeAllTabs -> closeAllTabs() R.id.downloads -> showDownloads() R.id.settings -> showSettings() @@ -341,6 +351,8 @@ 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) + duckChatMenuItem?.isVisible = duckChat.showInBrowserMenu() return super.onPrepareOptionsMenu(menu) } @@ -382,6 +394,13 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine launch { viewModel.onTabSelected(tab) } } + private fun launchDuckChat() { + globalActivityStarter.start(this, WebViewActivityWithParams( + url = DuckChat.DUCK_CHAT_WEB_LINK, + screenTitle = getString(R.string.duckChatScreenTitle), + )) + } + private fun updateTabGridItemDecorator(tab: TabEntity) { tabItemDecorator.selectedTabId = tab.tabId tabsRecycler.invalidateItemDecorations() 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..fbb3b9367f79 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"/> + + + + + + + + + New Tab Page Specific Page + + + Chat + AI Chat + DuckDuckGo AI Chat + \ No newline at end of file diff --git a/common/common-ui/src/main/res/drawable/ic_ai_chat_16.xml b/common/common-ui/src/main/res/drawable/ic_ai_chat_16.xml new file mode 100644 index 000000000000..b308974f58a9 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/ic_ai_chat_16.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + 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..5983191d3450 --- /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..29856f3efef7 --- /dev/null +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt @@ -0,0 +1,34 @@ +/* + * 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 with the DuckChat. + */ +interface DuckChat { + /** + * Checks whether DuckChat should be shown in browser menu + * + * @return true if DuckChat should be shown, false otherwise. + */ + fun showInBrowserMenu(): Boolean + + companion object { + /** Default link to DuckChat that identifies Android as the source */ + const val DUCK_CHAT_WEB_LINK = "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5" + } +} 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..fa21c6e9d2dc --- /dev/null +++ b/duckchat/duckchat-impl/build.gradle @@ -0,0 +1,78 @@ +/* + * 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') + + 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..414123358055 --- /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..2411a5ae50a1 --- /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 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 +import javax.inject.Inject + +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/DuckChatFeatureRepository.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeatureRepository.kt new file mode 100644 index 000000000000..c02e3f0bd93e --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatFeatureRepository.kt @@ -0,0 +1,54 @@ +/* + * 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.app.di.AppCoroutineScope +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface DuckChatFeatureRepository { + fun setShowInBrowserMenu(showDuckChat: Boolean) + fun observeShowInBrowserMenu(): Flow + fun shouldShowInBrowserMenu(): Boolean +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealDuckChatFeatureRepository @Inject constructor( + private val duckChatDataStore: DuckChatDataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : DuckChatFeatureRepository { + + override fun setShowInBrowserMenu(showDuckChat: Boolean) { + appCoroutineScope.launch { + 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..43619d1a13ed --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsActivity.kt @@ -0,0 +1,69 @@ +/* + * 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.common.ui.DuckDuckGoActivity +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 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() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + + // TODO: learn more link + + setupToolbar(binding.includeToolbar.toolbar) + + configureUiEventHandlers() + observeViewModel() + } + + private fun configureUiEventHandlers() { + binding.showDuckChatInMenuToggle.setOnCheckedChangeListener { _, isChecked -> + viewModel.onShowDuckChatInMenuToggled(isChecked) + } + } + + private fun observeViewModel() { + viewModel.showInBrowserMenu + .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { showInBrowserMenu -> renderViewState(showInBrowserMenu) } + .launchIn(lifecycleScope) + } + + private fun renderViewState(showInBrowserMenu: Boolean) { + binding.showDuckChatInMenuToggle.setIsChecked(showInBrowserMenu) + } +} 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..26364fe2dd50 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatSettingsViewModel.kt @@ -0,0 +1,39 @@ +/* + * 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.di.scopes.ActivityScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@ContributesViewModel(ActivityScope::class) +class DuckChatSettingsViewModel @Inject constructor( + private val duckChat: DuckChatInternal, +) : ViewModel() { + + val showInBrowserMenu: StateFlow = duckChat.observeShowInBrowserMenu() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), true) + + fun onShowDuckChatInMenuToggled(checked: Boolean) { + duckChat.setShowInBrowserMenu(checked) + } +} 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..481ca76bc501 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -0,0 +1,50 @@ +/* + * 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.duckduckgo.duckchat.api.DuckChat +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface DuckChatInternal : DuckChat { + /** + * Stores setting to determine whether the DuckChat should be shown in browser menu + */ + fun setShowInBrowserMenu(showDuckChat: Boolean) + + fun observeShowInBrowserMenu(): Flow +} + +@ContributesBinding(AppScope::class, boundType = DuckChat::class) +@ContributesBinding(AppScope::class, boundType = DuckChatInternal::class) +class RealDuckChat @Inject constructor( + private val duckChatFeatureRepository: DuckChatFeatureRepository, +) : DuckChatInternal { + override fun setShowInBrowserMenu(showDuckChat: Boolean) { + duckChatFeatureRepository.setShowInBrowserMenu(showDuckChat) + } + + override fun observeShowInBrowserMenu(): Flow { + return duckChatFeatureRepository.observeShowInBrowserMenu() + } + + override fun showInBrowserMenu(): Boolean { + return duckChatFeatureRepository.shouldShowInBrowserMenu() + } +} 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..c74def9e7a11 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_settings.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/duckchat/duckchat-impl/src/main/res/values/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values/strings-duckchat.xml new file mode 100644 index 000000000000..db5ac471b1c2 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/values/strings-duckchat.xml @@ -0,0 +1,21 @@ + + + + AI Chat + 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