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 ff2ca1b76e5f..3731f6a3dc61 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -98,8 +98,6 @@ import com.duckduckgo.app.browser.commands.NavigationCommand import com.duckduckgo.app.browser.commands.NavigationCommand.Navigate import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment -import com.duckduckgo.app.browser.duckchat.DuckChatJSHelper -import com.duckduckgo.app.browser.duckchat.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_PAGE_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DuckPlayerJSHelper @@ -220,7 +218,9 @@ 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.duckchat.impl.DuckChatJSHelper import com.duckduckgo.duckchat.impl.DuckChatPixelName +import com.duckduckgo.duckchat.impl.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.AUTO import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerOrigin.OVERLAY @@ -5870,8 +5870,8 @@ class BrowserTabViewModelTest { @Test fun whenProcessJsCallbackMessageForDuckChatThenSendCommand() = runTest { whenever(mockEnabledToggle.isEnabled()).thenReturn(true) - val sendResponseToJs = Command.SendResponseToJs(JsCallbackData(JSONObject(), "", "", "")) - whenever(mockDuckChatJSHelper.processJsCallbackMessage(anyString(), anyString(), anyOrNull(), anyOrNull())).thenReturn(sendResponseToJs) + val jsCallbackData = JsCallbackData(JSONObject(), "", "", "") + whenever(mockDuckChatJSHelper.processJsCallbackMessage(anyString(), anyString(), anyOrNull(), anyOrNull())).thenReturn(jsCallbackData) testee.processJsCallbackMessage( DUCK_CHAT_FEATURE_NAME, "method", 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 065999175b15..b598cce293f9 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -169,8 +169,6 @@ import com.duckduckgo.app.browser.commands.Command.WebViewError import com.duckduckgo.app.browser.commands.NavigationCommand import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment -import com.duckduckgo.app.browser.duckchat.DuckChatJSHelper -import com.duckduckgo.app.browser.duckchat.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_PAGE_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DuckPlayerJSHelper @@ -299,7 +297,9 @@ 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.duckchat.impl.DuckChatJSHelper import com.duckduckgo.duckchat.impl.DuckChatPixelName +import com.duckduckgo.duckchat.impl.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.history.api.NavigationHistory @@ -3533,7 +3533,7 @@ class BrowserTabViewModel @Inject constructor( val response = duckChatJSHelper.processJsCallbackMessage(featureName, method, id, data) withContext(dispatchers.main()) { response?.let { - command.value = it + command.value = SendResponseToJs(it) } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatPreferencesStore.kt b/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatPreferencesStore.kt deleted file mode 100644 index 486b55062685..000000000000 --- a/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatPreferencesStore.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.app.browser.duckchat - -import androidx.core.content.edit -import com.duckduckgo.data.store.api.SharedPreferencesProvider -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject - -interface DuckChatPreferencesStore { - fun fetchAndClearUserPreferences(): String? - fun updateUserPreferences(userPreferences: String?) -} - -@ContributesBinding(AppScope::class) -class RealDuckChatPreferencesStore @Inject constructor( - private val sharedPreferencesProvider: SharedPreferencesProvider, -) : DuckChatPreferencesStore { - - private val preferences by lazy { - sharedPreferencesProvider.getSharedPreferences(FILENAME) - } - - override fun fetchAndClearUserPreferences(): String? = - preferences.getString(USER_PREFERENCES, null).also { - preferences.edit { remove(USER_PREFERENCES) } - } - - override fun updateUserPreferences(userPreferences: String?) { - preferences.edit { - putString(USER_PREFERENCES, userPreferences) - } - } - - companion object { - const val FILENAME = "com.duckduckgo.app.duckchat" - const val USER_PREFERENCES = "DUCK_CHAT_USER_PREFERENCES" - } -} diff --git a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt index 33fc37396473..a59e74eb7066 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/webview/WebViewActivity.kt @@ -27,28 +27,16 @@ import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.BrowserWebViewClient -import com.duckduckgo.app.browser.commands.Command.SendResponseToJs import com.duckduckgo.app.browser.databinding.ActivityWebviewBinding -import com.duckduckgo.app.browser.duckchat.DuckChatJSHelper -import com.duckduckgo.app.browser.duckchat.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME -import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.js.messaging.api.JsMessageCallback -import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.navigation.api.getActivityParams import com.duckduckgo.user.agent.api.UserAgentProvider import javax.inject.Inject -import javax.inject.Named -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.json.JSONObject @InjectWith(ActivityScope::class) @ContributeToActivityStarter(WebViewActivityWithParams::class) @@ -63,20 +51,6 @@ class WebViewActivity : DuckDuckGoActivity() { @Inject lateinit var pixel: Pixel - @Inject - @Named("ContentScopeScripts") - lateinit var contentScopeScripts: JsMessaging - - @Inject - lateinit var duckChatJSHelper: DuckChatJSHelper - - @Inject - @AppCoroutineScope - lateinit var appCoroutineScope: CoroutineScope - - @Inject - lateinit var dispatcherProvider: DispatcherProvider - private val binding: ActivityWebviewBinding by viewBinding() private val toolbar @@ -132,33 +106,6 @@ class WebViewActivity : DuckDuckGoActivity() { databaseEnabled = false setSupportZoom(true) } - - contentScopeScripts.register( - it, - object : JsMessageCallback() { - override fun process( - featureName: String, - method: String, - id: String?, - data: JSONObject?, - ) { - when (featureName) { - DUCK_CHAT_FEATURE_NAME -> { - appCoroutineScope.launch(dispatcherProvider.io()) { - val response = duckChatJSHelper.processJsCallbackMessage(featureName, method, id, data) - if (response is SendResponseToJs) { - withContext(dispatcherProvider.main()) { - contentScopeScripts.onResponse(response.data) - } - } - } - } - - else -> {} - } - } - }, - ) } url?.let { diff --git a/app/src/test/java/com/duckduckgo/app/browser/duckchat/RealDuckChatPreferencesStoreTest.kt b/app/src/test/java/com/duckduckgo/app/browser/duckchat/RealDuckChatPreferencesStoreTest.kt deleted file mode 100644 index f08d3c01d6f1..000000000000 --- a/app/src/test/java/com/duckduckgo/app/browser/duckchat/RealDuckChatPreferencesStoreTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2025 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.app.browser.duckchat - -import android.content.SharedPreferences -import com.duckduckgo.app.browser.duckchat.RealDuckChatPreferencesStore.Companion.USER_PREFERENCES -import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.common.test.api.InMemorySharedPreferences -import com.duckduckgo.data.store.api.SharedPreferencesProvider -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -class RealDuckChatPreferencesStoreTest { - - @get:Rule - var coroutineRule = CoroutineTestRule() - - private lateinit var preferences: SharedPreferences - private lateinit var testee: RealDuckChatPreferencesStore - - @Before - fun setup() = runTest { - preferences = InMemorySharedPreferences() - - testee = RealDuckChatPreferencesStore( - object : SharedPreferencesProvider { - override fun getSharedPreferences(name: String, multiprocess: Boolean, migrate: Boolean): SharedPreferences { - return preferences - } - - override fun getEncryptedSharedPreferences(name: String, multiprocess: Boolean): SharedPreferences { - return preferences - } - }, - ) - } - - @Test - fun whenFetchAndClearUserPreferencesAndPreferencesExistThenReturnAndClearPreferences() = runTest { - val storedPreferences = "userPreferences" - preferences.edit().putString(USER_PREFERENCES, storedPreferences).apply() - - val result = testee.fetchAndClearUserPreferences() - - assertEquals(storedPreferences, result) - assertNull(preferences.getString(USER_PREFERENCES, null)) - assertNull(testee.fetchAndClearUserPreferences()) - } - - @Test - fun whenFetchAndClearUserPreferencesAndNoPreferencesExistThenReturnNull() = runTest { - val result = testee.fetchAndClearUserPreferences() - - assertNull(result) - } - - @Test - fun whenUpdateUserPreferencesThenStoreProvidedPreferences() = runTest { - val newPreferences = "newUserPreferences" - - testee.updateUserPreferences(newPreferences) - - assertEquals(newPreferences, preferences.getString(USER_PREFERENCES, null)) - } - - @Test - fun whenUpdateUserPreferencesWithNullThenStoreNullPreferences() = runTest { - testee.updateUserPreferences(null) - - assertNull(preferences.getString(USER_PREFERENCES, null)) - } -} diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/ui/BrowserScreens.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/ui/BrowserScreens.kt index 74a1eb7e0a82..4142c3c73b2a 100644 --- a/browser-api/src/main/java/com/duckduckgo/browser/api/ui/BrowserScreens.kt +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/ui/BrowserScreens.kt @@ -22,13 +22,21 @@ import com.duckduckgo.navigation.api.GlobalActivityStarter * Model that represents the Browser Screens hosted inside :app module that can be launched from other modules. */ sealed class BrowserScreens { - object FeedbackActivityWithEmptyParams : GlobalActivityStarter.ActivityParams + + /** + * Use this model to launch the standalone WebView + */ data class WebViewActivityWithParams( val url: String, val screenTitle: String, val supportNewWindows: Boolean = false, ) : GlobalActivityStarter.ActivityParams + /** + * Use this model to launch the Feedback screen + */ + object FeedbackActivityWithEmptyParams : GlobalActivityStarter.ActivityParams + /** * Use this model to launch the Bookmarks screen */ 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 index 06aa6e3f5148..05c9e9f0c74c 100644 --- 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 @@ -19,6 +19,6 @@ package com.duckduckgo.duckchat.api import com.duckduckgo.navigation.api.GlobalActivityStarter /** - * Use this model to launch the Duck Player Settings screen + * Use this model to launch the DuckChat Settings screen */ object DuckChatSettingsNoParams : GlobalActivityStarter.ActivityParams diff --git a/duckchat/duckchat-impl/build.gradle b/duckchat/duckchat-impl/build.gradle index 039e5a4f26fc..a17dcb76a1b4 100644 --- a/duckchat/duckchat-impl/build.gradle +++ b/duckchat/duckchat-impl/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation project(':common-ui') implementation project(':common-utils') implementation project(':browser-api') + implementation project(':js-messaging-api') anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') diff --git a/duckchat/duckchat-impl/src/main/AndroidManifest.xml b/duckchat/duckchat-impl/src/main/AndroidManifest.xml index 85562b4c3388..b90168e9029b 100644 --- a/duckchat/duckchat-impl/src/main/AndroidManifest.xml +++ b/duckchat/duckchat-impl/src/main/AndroidManifest.xml @@ -22,5 +22,9 @@ android:exported="false" android:label="@string/duck_chat_title" android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity" /> + \ 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 index 11dd64ba7fdd..a5194e05c4e9 100644 --- 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 @@ -20,6 +20,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.impl.SharedPreferencesDuckChatDataStore.Keys.DUCK_CHAT_OPENED @@ -40,6 +41,8 @@ interface DuckChatDataStore { suspend fun setShowInBrowserMenu(showDuckChat: Boolean) fun observeShowInBrowserMenu(): Flow fun getShowInBrowserMenu(): Boolean + suspend fun fetchAndClearUserPreferences(): String? + suspend fun updateUserPreferences(userPreferences: String?) suspend fun registerOpened() suspend fun wasOpenedBefore(): Boolean } @@ -54,6 +57,7 @@ class SharedPreferencesDuckChatDataStore @Inject constructor( private object Keys { val DUCK_CHAT_SHOW_IN_MENU = booleanPreferencesKey(name = "DUCK_CHAT_SHOW_IN_MENU") val DUCK_CHAT_OPENED = booleanPreferencesKey(name = "DUCK_CHAT_OPENED") + val DUCK_CHAT_USER_PREFERENCES = stringPreferencesKey("DUCK_CHAT_USER_PREFERENCES") } private val duckChatShowInBrowserMenu: StateFlow = store.data @@ -75,6 +79,22 @@ class SharedPreferencesDuckChatDataStore @Inject constructor( return duckChatShowInBrowserMenu.value } + override suspend fun fetchAndClearUserPreferences(): String? { + val userPreferences = store.data.map { it[Keys.DUCK_CHAT_USER_PREFERENCES] }.firstOrNull() + store.edit { prefs -> prefs.remove(Keys.DUCK_CHAT_USER_PREFERENCES) } + return userPreferences + } + + override suspend fun updateUserPreferences(userPreferences: String?) { + store.edit { prefs -> + if (userPreferences == null) { + prefs.remove(Keys.DUCK_CHAT_USER_PREFERENCES) + } else { + prefs[Keys.DUCK_CHAT_USER_PREFERENCES] = userPreferences + } + } + } + override suspend fun registerOpened() { store.edit { it[DUCK_CHAT_OPENED] = true } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatJSHelper.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatJSHelper.kt similarity index 84% rename from app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatJSHelper.kt rename to duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatJSHelper.kt index 45688117f07a..a73614472db3 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/duckchat/DuckChatJSHelper.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatJSHelper.kt @@ -14,15 +14,14 @@ * limitations under the License. */ -package com.duckduckgo.app.browser.duckchat +package com.duckduckgo.duckchat.impl -import com.duckduckgo.app.browser.commands.Command -import com.duckduckgo.app.browser.commands.Command.SendResponseToJs import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.js.messaging.api.JsCallbackData import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject +import kotlinx.coroutines.runBlocking import org.json.JSONObject interface DuckChatJSHelper { @@ -31,13 +30,13 @@ interface DuckChatJSHelper { method: String, id: String?, data: JSONObject?, - ): Command? + ): JsCallbackData? } @ContributesBinding(AppScope::class) class RealDuckChatJSHelper @Inject constructor( private val duckChat: DuckChat, - private val preferencesStore: DuckChatPreferencesStore, + private val dataStore: DuckChatDataStore, ) : DuckChatJSHelper { override suspend fun processJsCallbackMessage( @@ -45,16 +44,16 @@ class RealDuckChatJSHelper @Inject constructor( method: String, id: String?, data: JSONObject?, - ): Command? = when (method) { + ): JsCallbackData? = when (method) { METHOD_GET_AI_CHAT_NATIVE_HANDOFF_DATA -> id?.let { - SendResponseToJs(getAIChatNativeHandoffData(featureName, method, it)) + getAIChatNativeHandoffData(featureName, method, it) } METHOD_GET_AI_CHAT_NATIVE_CONFIG_VALUES -> id?.let { - SendResponseToJs(getAIChatNativeConfigValues(featureName, method, it)) + getAIChatNativeConfigValues(featureName, method, it) } METHOD_OPEN_AI_CHAT -> { val payload = extractPayload(data) - preferencesStore.updateUserPreferences(payload) + dataStore.updateUserPreferences(payload) duckChat.openDuckChat() null } @@ -65,7 +64,7 @@ class RealDuckChatJSHelper @Inject constructor( val jsonPayload = JSONObject().apply { put(PLATFORM, ANDROID) put(IS_HANDOFF_ENABLED, duckChat.isEnabled()) - put(PAYLOAD, preferencesStore.fetchAndClearUserPreferences()) + put(PAYLOAD, runBlocking { dataStore.fetchAndClearUserPreferences() }) } return JsCallbackData(jsonPayload, featureName, method, id) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatWebViewActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatWebViewActivity.kt new file mode 100644 index 000000000000..3b91a29e5e11 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatWebViewActivity.kt @@ -0,0 +1,176 @@ +/* + * 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 android.annotation.SuppressLint +import android.os.Bundle +import android.os.Message +import android.view.MenuItem +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.tabs.BrowserNav +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.impl.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME +import com.duckduckgo.duckchat.impl.databinding.ActivityDuckChatWebviewBinding +import com.duckduckgo.js.messaging.api.JsMessageCallback +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.navigation.api.getActivityParams +import javax.inject.Inject +import javax.inject.Named +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +internal data class DuckChatWebViewActivityWithParams( + val url: String, +) : GlobalActivityStarter.ActivityParams + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(DuckChatWebViewActivityWithParams::class) +class DuckChatWebViewActivity : DuckDuckGoActivity() { + + @Inject + lateinit var webViewClient: DuckChatWebViewClient + + @Inject + @Named("ContentScopeScripts") + lateinit var contentScopeScripts: JsMessaging + + @Inject + lateinit var duckChatJSHelper: DuckChatJSHelper + + @Inject + @AppCoroutineScope + lateinit var appCoroutineScope: CoroutineScope + + @Inject + lateinit var dispatcherProvider: DispatcherProvider + + @Inject + lateinit var browserNav: BrowserNav + + private val binding: ActivityDuckChatWebviewBinding by viewBinding() + + private val toolbar + get() = binding.includeToolbar.toolbar + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + setupToolbar(toolbar) + + val params = intent.getActivityParams(DuckChatWebViewActivityWithParams::class.java) + val url = params?.url + + binding.simpleWebview.let { + it.webViewClient = webViewClient + it.webChromeClient = object : WebChromeClient() { + override fun onCreateWindow( + view: WebView?, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message?, + ): Boolean { + view?.requestFocusNodeHref(resultMsg) + val newWindowUrl = resultMsg?.data?.getString("url") + if (newWindowUrl != null) { + startActivity(browserNav.openInNewTab(this@DuckChatWebViewActivity, newWindowUrl)) + return true + } + return false + } + } + + it.settings.apply { + userAgentString = CUSTOM_UA + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + setSupportMultipleWindows(true) + databaseEnabled = false + setSupportZoom(true) + } + + contentScopeScripts.register( + it, + object : JsMessageCallback() { + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + when (featureName) { + DUCK_CHAT_FEATURE_NAME -> { + appCoroutineScope.launch(dispatcherProvider.io()) { + duckChatJSHelper.processJsCallbackMessage(featureName, method, id, data)?.let { response -> + withContext(dispatcherProvider.main()) { + contentScopeScripts.onResponse(response) + } + } + } + } + else -> {} + } + } + }, + ) + } + + url?.let { + binding.simpleWebview.loadUrl(it) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + super.onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun onBackPressed() { + if (binding.simpleWebview.canGoBack()) { + binding.simpleWebview.goBack() + } else { + super.onBackPressed() + } + } + + companion object { + private const val CUSTOM_UA = + "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile DuckDuckGo/5 Safari/537.36" + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatWebViewClient.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatWebViewClient.kt new file mode 100644 index 000000000000..c8f285545e23 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatWebViewClient.kt @@ -0,0 +1,41 @@ +/* + * 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 android.graphics.Bitmap +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.annotation.UiThread +import com.duckduckgo.browser.api.JsInjectorPlugin +import com.duckduckgo.common.utils.plugins.PluginPoint +import javax.inject.Inject + +class DuckChatWebViewClient @Inject constructor( + private val jsPlugins: PluginPoint, +) : WebViewClient() { + + @UiThread + override fun onPageStarted( + webView: WebView, + url: String?, + favicon: Bitmap?, + ) { + jsPlugins.getPlugins().forEach { + it.onPageStarted(webView, url, null) + } + } +} 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 d88487bca1ec..acf86779ac1d 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 @@ -23,7 +23,6 @@ import androidx.core.net.toUri import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.di.IsMainProcess import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.common.utils.AppUrl.ParamKey.QUERY import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope @@ -149,13 +148,10 @@ class RealDuckChat @Inject constructor( private fun startDuckChatActivity(url: String) { val intent = globalActivityStarter.startIntent( context, - WebViewActivityWithParams( + DuckChatWebViewActivityWithParams( url = url, - screenTitle = context.getString(R.string.duck_chat_title), - supportNewWindows = true, ), ) - intent?.let { it.flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(it) diff --git a/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_webview.xml b/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_webview.xml new file mode 100644 index 000000000000..c64aa696b679 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_webview.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/DuckChatWebViewClientTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/DuckChatWebViewClientTest.kt new file mode 100644 index 000000000000..5109188a6026 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/DuckChatWebViewClientTest.kt @@ -0,0 +1,27 @@ +package com.duckduckgo.duckchat.impl + +import android.webkit.WebView +import com.duckduckgo.browser.api.JsInjectorPlugin +import com.duckduckgo.common.utils.plugins.PluginPoint +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class DuckChatWebViewClientTest { + + @Test + fun whenOnPageStartedCalledThenJsPluginOnPageStartedInvoked() { + val mockPlugin: JsInjectorPlugin = mock() + val pluginPoint: PluginPoint = mock() + whenever(pluginPoint.getPlugins()).thenReturn(listOf(mockPlugin)) + + val duckChatWebViewClient = DuckChatWebViewClient(pluginPoint) + val webView: WebView = mock() + val url = "https://example.com" + + duckChatWebViewClient.onPageStarted(webView, url, null) + + verify(mockPlugin).onPageStarted(webView, url, null) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/browser/duckchat/RealDuckChatJSHelperTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatJSHelperTest.kt similarity index 62% rename from app/src/test/java/com/duckduckgo/app/browser/duckchat/RealDuckChatJSHelperTest.kt rename to duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatJSHelperTest.kt index ecd1f20d0b2f..8bda5dff912c 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/duckchat/RealDuckChatJSHelperTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatJSHelperTest.kt @@ -14,10 +14,9 @@ * limitations under the License. */ -package com.duckduckgo.app.browser.duckchat +package com.duckduckgo.duckchat.impl import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.app.browser.commands.Command.SendResponseToJs import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.js.messaging.api.JsCallbackData @@ -39,11 +38,11 @@ class RealDuckChatJSHelperTest { var coroutineRule = CoroutineTestRule() private val mockDuckChat: DuckChat = mock() - private val mockPreferencesStore: DuckChatPreferencesStore = mock() + private val mockDataStore: DuckChatDataStore = mock() private val testee = RealDuckChatJSHelper( duckChat = mockDuckChat, - preferencesStore = mockPreferencesStore, + dataStore = mockDataStore, ) @Test @@ -68,15 +67,15 @@ class RealDuckChatJSHelperTest { } @Test - fun whenGetAIChatNativeHandoffDataAndDuckChatEnabledThenReturnSendResponseToJsWithDuckChatEnabled() = runTest { + fun whenGetAIChatNativeHandoffDataAndDuckChatEnabledThenReturnJsCallbackDataWithDuckChatEnabled() = runTest { val featureName = "aiChat" val method = "getAIChatNativeHandoffData" val id = "123" whenever(mockDuckChat.isEnabled()).thenReturn(true) - whenever(mockPreferencesStore.fetchAndClearUserPreferences()).thenReturn("preferences") + whenever(mockDataStore.fetchAndClearUserPreferences()).thenReturn("preferences") - val result = testee.processJsCallbackMessage(featureName, method, id, null) as SendResponseToJs + val result = testee.processJsCallbackMessage(featureName, method, id, null) val jsonPayload = JSONObject().apply { put("platform", "android") @@ -84,24 +83,24 @@ class RealDuckChatJSHelperTest { put("aiChatPayload", "preferences") } - val expected = SendResponseToJs(JsCallbackData(jsonPayload, featureName, method, id)) + val expected = JsCallbackData(jsonPayload, featureName, method, id) - assertEquals(expected.data.id, result.data.id) - assertEquals(expected.data.method, result.data.method) - assertEquals(expected.data.featureName, result.data.featureName) - assertEquals(expected.data.params.toString(), result.data.params.toString()) + assertEquals(expected.id, result!!.id) + assertEquals(expected.method, result.method) + assertEquals(expected.featureName, result.featureName) + assertEquals(expected.params.toString(), result.params.toString()) } @Test - fun whenGetAIChatNativeHandoffDataAndDuckChatDisabledThenReturnSendResponseToJsWithDuckChatDisabled() = runTest { + fun whenGetAIChatNativeHandoffDataAndDuckChatDisabledThenReturnJsCallbackDataWithDuckChatDisabled() = runTest { val featureName = "aiChat" val method = "getAIChatNativeHandoffData" val id = "123" whenever(mockDuckChat.isEnabled()).thenReturn(false) - whenever(mockPreferencesStore.fetchAndClearUserPreferences()).thenReturn("preferences") + whenever(mockDataStore.fetchAndClearUserPreferences()).thenReturn("preferences") - val result = testee.processJsCallbackMessage(featureName, method, id, null) as SendResponseToJs + val result = testee.processJsCallbackMessage(featureName, method, id, null) val jsonPayload = JSONObject().apply { put("platform", "android") @@ -109,24 +108,24 @@ class RealDuckChatJSHelperTest { put("aiChatPayload", "preferences") } - val expected = SendResponseToJs(JsCallbackData(jsonPayload, featureName, method, id)) + val expected = JsCallbackData(jsonPayload, featureName, method, id) - assertEquals(expected.data.id, result.data.id) - assertEquals(expected.data.method, result.data.method) - assertEquals(expected.data.featureName, result.data.featureName) - assertEquals(expected.data.params.toString(), result.data.params.toString()) + assertEquals(expected.id, result!!.id) + assertEquals(expected.method, result.method) + assertEquals(expected.featureName, result.featureName) + assertEquals(expected.params.toString(), result.params.toString()) } @Test - fun whenGetAIChatNativeHandoffDataAndPreferencesNullThenReturnSendResponseToJsWithPreferencesNull() = runTest { + fun whenGetAIChatNativeHandoffDataAndPreferencesNullThenReturnJsCallbackDataWithPreferencesNull() = runTest { val featureName = "aiChat" val method = "getAIChatNativeHandoffData" val id = "123" whenever(mockDuckChat.isEnabled()).thenReturn(true) - whenever(mockPreferencesStore.fetchAndClearUserPreferences()).thenReturn(null) + whenever(mockDataStore.fetchAndClearUserPreferences()).thenReturn(null) - val result = testee.processJsCallbackMessage(featureName, method, id, null) as SendResponseToJs + val result = testee.processJsCallbackMessage(featureName, method, id, null) val jsonPayload = JSONObject().apply { put("platform", "android") @@ -134,12 +133,12 @@ class RealDuckChatJSHelperTest { put("aiChatPayload", null) } - val expected = SendResponseToJs(JsCallbackData(jsonPayload, featureName, method, id)) + val expected = JsCallbackData(jsonPayload, featureName, method, id) - assertEquals(expected.data.id, result.data.id) - assertEquals(expected.data.method, result.data.method) - assertEquals(expected.data.featureName, result.data.featureName) - assertEquals(expected.data.params.toString(), result.data.params.toString()) + assertEquals(expected.id, result!!.id) + assertEquals(expected.method, result.method) + assertEquals(expected.featureName, result.featureName) + assertEquals(expected.params.toString(), result.params.toString()) } @Test @@ -153,63 +152,63 @@ class RealDuckChatJSHelperTest { } @Test - fun whenGetAIChatNativeConfigValuesAndDuckChatEnabledThenReturnSendResponseToJsWithDuckChatEnabled() = runTest { + fun whenGetAIChatNativeConfigValuesAndDuckChatEnabledThenReturnJsCallbackDataWithDuckChatEnabled() = runTest { val featureName = "aiChat" val method = "getAIChatNativeConfigValues" val id = "123" whenever(mockDuckChat.isEnabled()).thenReturn(true) - whenever(mockPreferencesStore.fetchAndClearUserPreferences()).thenReturn("preferences") + whenever(mockDataStore.fetchAndClearUserPreferences()).thenReturn("preferences") - val result = testee.processJsCallbackMessage(featureName, method, id, null) as SendResponseToJs + val result = testee.processJsCallbackMessage(featureName, method, id, null) val jsonPayload = JSONObject().apply { put("platform", "android") put("isAIChatHandoffEnabled", true) } - val expected = SendResponseToJs(JsCallbackData(jsonPayload, featureName, method, id)) + val expected = JsCallbackData(jsonPayload, featureName, method, id) - assertEquals(expected.data.id, result.data.id) - assertEquals(expected.data.method, result.data.method) - assertEquals(expected.data.featureName, result.data.featureName) - assertEquals(expected.data.params.toString(), result.data.params.toString()) + assertEquals(expected.id, result!!.id) + assertEquals(expected.method, result.method) + assertEquals(expected.featureName, result.featureName) + assertEquals(expected.params.toString(), result.params.toString()) } @Test - fun whenGetAIChatNativeConfigValuesAndDuckChatDisabledThenReturnSendResponseToJsWithDuckChatDisabled() = runTest { + fun whenGetAIChatNativeConfigValuesAndDuckChatDisabledThenReturnJsCallbackDataWithDuckChatDisabled() = runTest { val featureName = "aiChat" val method = "getAIChatNativeConfigValues" val id = "123" whenever(mockDuckChat.isEnabled()).thenReturn(false) - whenever(mockPreferencesStore.fetchAndClearUserPreferences()).thenReturn("preferences") + whenever(mockDataStore.fetchAndClearUserPreferences()).thenReturn("preferences") - val result = testee.processJsCallbackMessage(featureName, method, id, null) as SendResponseToJs + val result = testee.processJsCallbackMessage(featureName, method, id, null) val jsonPayload = JSONObject().apply { put("platform", "android") put("isAIChatHandoffEnabled", false) } - val expected = SendResponseToJs(JsCallbackData(jsonPayload, featureName, method, id)) + val expected = JsCallbackData(jsonPayload, featureName, method, id) - assertEquals(expected.data.id, result.data.id) - assertEquals(expected.data.method, result.data.method) - assertEquals(expected.data.featureName, result.data.featureName) - assertEquals(expected.data.params.toString(), result.data.params.toString()) + assertEquals(expected.id, result!!.id) + assertEquals(expected.method, result.method) + assertEquals(expected.featureName, result.featureName) + assertEquals(expected.params.toString(), result.params.toString()) } @Test - fun whenGetAIChatNativeConfigValuesAndPreferencesNullThenReturnSendResponseToJsWithPreferencesNull() = runTest { + fun whenGetAIChatNativeConfigValuesAndPreferencesNullThenReturnJsCallbackDataWithPreferencesNull() = runTest { val featureName = "aiChat" val method = "getAIChatNativeConfigValues" val id = "123" whenever(mockDuckChat.isEnabled()).thenReturn(true) - whenever(mockPreferencesStore.fetchAndClearUserPreferences()).thenReturn(null) + whenever(mockDataStore.fetchAndClearUserPreferences()).thenReturn(null) - val result = testee.processJsCallbackMessage(featureName, method, id, null) as SendResponseToJs + val result = testee.processJsCallbackMessage(featureName, method, id, null) val jsonPayload = JSONObject().apply { put("platform", "android") @@ -217,12 +216,12 @@ class RealDuckChatJSHelperTest { put("aiChatPayload", null) } - val expected = SendResponseToJs(JsCallbackData(jsonPayload, featureName, method, id)) + val expected = JsCallbackData(jsonPayload, featureName, method, id) - assertEquals(expected.data.id, result.data.id) - assertEquals(expected.data.method, result.data.method) - assertEquals(expected.data.featureName, result.data.featureName) - assertEquals(expected.data.params.toString(), result.data.params.toString()) + assertEquals(expected.id, result!!.id) + assertEquals(expected.method, result.method) + assertEquals(expected.featureName, result.featureName) + assertEquals(expected.params.toString(), result.params.toString()) } @Test @@ -236,7 +235,7 @@ class RealDuckChatJSHelperTest { assertNull(testee.processJsCallbackMessage(featureName, method, id, data)) - verify(mockPreferencesStore).updateUserPreferences(payloadString) + verify(mockDataStore).updateUserPreferences(payloadString) verify(mockDuckChat).openDuckChat() } @@ -247,7 +246,7 @@ class RealDuckChatJSHelperTest { val id = "123" assertNull(testee.processJsCallbackMessage(featureName, method, id, null)) - verify(mockPreferencesStore).updateUserPreferences(null) + verify(mockDataStore).updateUserPreferences(null) verify(mockDuckChat).openDuckChat() } @@ -259,7 +258,7 @@ class RealDuckChatJSHelperTest { val data = JSONObject(mapOf("aiChatPayload" to JSONObject.NULL)) assertNull(testee.processJsCallbackMessage(featureName, method, id, data)) - verify(mockPreferencesStore).updateUserPreferences(null) + verify(mockDataStore).updateUserPreferences(null) verify(mockDuckChat).openDuckChat() } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt index ce70cb14ea5f..76a2f58c608b 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt @@ -21,7 +21,6 @@ import android.content.Intent import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.navigation.api.GlobalActivityStarter @@ -73,7 +72,7 @@ class RealDuckChatTest { whenever(mockDuckPlayerFeatureRepository.shouldShowInBrowserMenu()).thenReturn(true) whenever(mockContext.getString(any())).thenReturn("Duck.ai") setFeatureToggle(true) - whenever(mockGlobalActivityStarter.startIntent(any(), any())).thenReturn(mockIntent) + whenever(mockGlobalActivityStarter.startIntent(any(), any())).thenReturn(mockIntent) } @Test @@ -130,10 +129,8 @@ class RealDuckChatTest { testee.openDuckChat() verify(mockGlobalActivityStarter).startIntent( mockContext, - WebViewActivityWithParams( + DuckChatWebViewActivityWithParams( url = "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5", - screenTitle = "Duck.ai", - supportNewWindows = true, ), ) verify(mockContext).startActivity(any()) @@ -145,10 +142,8 @@ class RealDuckChatTest { testee.openDuckChat(query = "example") verify(mockGlobalActivityStarter).startIntent( mockContext, - WebViewActivityWithParams( + DuckChatWebViewActivityWithParams( url = "https://duckduckgo.com/?q=example&ia=chat&duckai=5", - screenTitle = "Duck.ai", - supportNewWindows = true, ), ) verify(mockContext).startActivity(any()) @@ -160,10 +155,8 @@ class RealDuckChatTest { testee.openDuckChatWithAutoPrompt(query = "example") verify(mockGlobalActivityStarter).startIntent( mockContext, - WebViewActivityWithParams( + DuckChatWebViewActivityWithParams( url = "https://duckduckgo.com/?q=example&prompt=1&ia=chat&duckai=5", - screenTitle = "Duck.ai", - supportNewWindows = true, ), ) verify(mockContext).startActivity(any()) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/SharedPreferencesDuckChatDataStoreTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/SharedPreferencesDuckChatDataStoreTest.kt new file mode 100644 index 000000000000..58b415253aad --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/SharedPreferencesDuckChatDataStoreTest.kt @@ -0,0 +1,80 @@ +package com.duckduckgo.duckchat.impl + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SharedPreferencesDuckChatDataStoreTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val testDataStore: DataStore = + PreferenceDataStoreFactory.create( + scope = coroutineRule.testScope, + produceFile = { context.preferencesDataStoreFile("duck_chat_store") }, + ) + + private val testee: DuckChatDataStore = + SharedPreferencesDuckChatDataStore(testDataStore, coroutineRule.testScope) + + companion object { + val DUCK_CHAT_USER_PREFERENCES = stringPreferencesKey("DUCK_CHAT_USER_PREFERENCES") + } + + @Test + fun whenFetchAndClearUserPreferencesAndPreferencesExistThenReturnAndClearPreferences() = runTest { + val storedPreferences = "userPreferences" + testDataStore.updateData { current -> + current.toMutablePreferences().apply { + this[DUCK_CHAT_USER_PREFERENCES] = storedPreferences + } + } + + val result = testee.fetchAndClearUserPreferences() + + assertEquals(storedPreferences, result) + assertFalse(testDataStore.data.first().contains(DUCK_CHAT_USER_PREFERENCES)) + assertNull(testee.fetchAndClearUserPreferences()) + } + + @Test + fun whenFetchAndClearUserPreferencesAndNoPreferencesExistThenReturnNull() = runTest { + val result = testee.fetchAndClearUserPreferences() + + assertNull(result) + } + + @Test + fun whenUpdateUserPreferencesThenStoreProvidedPreferences() = runTest { + val newPreferences = "newUserPreferences" + + testee.updateUserPreferences(newPreferences) + + assertEquals(newPreferences, testDataStore.data.first()[DUCK_CHAT_USER_PREFERENCES]) + } + + @Test + fun whenUpdateUserPreferencesWithNullThenStoreNullPreferences() = runTest { + testee.updateUserPreferences(null) + + assertFalse(testDataStore.data.first().contains(DUCK_CHAT_USER_PREFERENCES)) + } +}