diff --git a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt index c23c952ed2e3..9ee4ad7f8d60 100644 --- a/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/privatesearch/PrivateSearchActivity.kt @@ -34,6 +34,7 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.settings.api.SettingsPageFeature +import com.duckduckgo.settings.api.SettingsWebViewScreenWithParams import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import javax.inject.Inject @@ -112,23 +113,27 @@ class PrivateSearchActivity : DuckDuckGoActivity() { } private fun launchCustomizeSearchWebPage() { - val settingsUrl = - if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { - DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM - } else { - DUCKDUCKGO_SETTINGS_WEB_LINK - } - globalActivityStarter.start( - this, - WebViewActivityWithParams( - url = settingsUrl, - getString(R.string.privateSearchMoreSearchSettingsTitle), - ), - ) + if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + globalActivityStarter.start( + this, + SettingsWebViewScreenWithParams( + url = DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM, + getString(R.string.privateSearchMoreSearchSettingsTitle), + ), + ) + } else { + globalActivityStarter.start( + this, + WebViewActivityWithParams( + url = DUCKDUCKGO_SETTINGS_WEB_LINK, + getString(R.string.privateSearchMoreSearchSettingsTitle), + ), + ) + } } companion object { private const val DUCKDUCKGO_SETTINGS_WEB_LINK = "https://duckduckgo.com/settings" - private const val DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?return=privateSearch" + private const val DUCKDUCKGO_SETTINGS_WEB_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?ko=-1&return=privateSearch" } } diff --git a/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/CoreContentScopeScripts.kt b/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/CoreContentScopeScripts.kt new file mode 100644 index 000000000000..f7adc86aca63 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-api/src/main/java/com/duckduckgo/contentscopescripts/api/CoreContentScopeScripts.kt @@ -0,0 +1,32 @@ +/* + * 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.contentscopescripts.api + +import com.duckduckgo.feature.toggles.api.Toggle + +interface CoreContentScopeScripts { + fun getScript( + isDesktopMode: Boolean?, + activeExperiments: List, + ): String + + fun isEnabled(): Boolean + + val secret: String + val javascriptInterface: String + val callbackName: String +} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPlugin.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPlugin.kt index e8d4bc874df8..dd9e08bf2288 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPlugin.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPlugin.kt @@ -19,6 +19,7 @@ package com.duckduckgo.contentscopescripts.impl import android.webkit.WebView import com.duckduckgo.app.global.model.Site import com.duckduckgo.browser.api.JsInjectorPlugin +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.Toggle import com.squareup.anvil.annotations.ContributesMultibinding @@ -39,7 +40,11 @@ class ContentScopeScriptsJsInjectorPlugin @Inject constructor( } } - override fun onPageFinished(webView: WebView, url: String?, site: Site?) { + override fun onPageFinished( + webView: WebView, + url: String?, + site: Site?, + ) { // NOOP } } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScripts.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScripts.kt index 8ce059892dd4..b7f14390163f 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScripts.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScripts.kt @@ -21,6 +21,7 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.isInternalBuild import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.FeatureException import com.duckduckgo.feature.toggles.api.Toggle @@ -37,19 +38,6 @@ import java.util.UUID import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject -interface CoreContentScopeScripts { - fun getScript( - isDesktopMode: Boolean?, - activeExperiments: List, - ): String - - fun isEnabled(): Boolean - - val secret: String - val javascriptInterface: String - val callbackName: String -} - @SingleInstanceIn(AppScope::class) @ContributesBinding(AppScope::class) class RealContentScopeScripts @Inject constructor( @@ -61,16 +49,15 @@ class RealContentScopeScripts @Inject constructor( private val fingerprintProtectionManager: FingerprintProtectionManager, private val contentScopeScriptsFeature: ContentScopeScriptsFeature, ) : CoreContentScopeScripts { - private var cachedContentScopeJson: String = getContentScopeJson("", emptyList()) private var cachedUserUnprotectedDomains = CopyOnWriteArrayList() - private var cachedUserUnprotectedDomainsJson: String = emptyJsonList + private var cachedUserUnprotectedDomainsJson: String = EMPTY_JSON_LIST - private var cachedUserPreferencesJson: String = emptyJson + private var cachedUserPreferencesJson: String = EMPTY_JSON private var cachedUnprotectTemporaryExceptions = CopyOnWriteArrayList() - private var cachedUnprotectTemporaryExceptionsJson: String = emptyJsonList + private var cachedUnprotectTemporaryExceptionsJson: String = EMPTY_JSON_LIST private lateinit var cachedContentScopeJS: String @@ -114,12 +101,12 @@ class RealContentScopeScripts @Inject constructor( return cachedContentScopeJS } - override fun isEnabled(): Boolean { - return contentScopeScriptsFeature.self().isEnabled() - } + override fun isEnabled(): Boolean = contentScopeScriptsFeature.self().isEnabled() private fun getSecretKeyValuePair() = "\"messageSecret\":\"$secret\"" + private fun getCallbackKeyValuePair() = "\"messageCallback\":\"$callbackName\"" + private fun getInterfaceKeyValuePair() = "\"javascriptInterface\":\"$javascriptInterface\"" private fun getPluginParameters(): PluginParameters { @@ -145,7 +132,7 @@ class RealContentScopeScripts @Inject constructor( private fun cacheUserUnprotectedDomains(userUnprotectedDomains: List) { cachedUserUnprotectedDomains.clear() if (userUnprotectedDomains.isEmpty()) { - cachedUserUnprotectedDomainsJson = emptyJsonList + cachedUserUnprotectedDomainsJson = EMPTY_JSON_LIST } else { cachedUserUnprotectedDomainsJson = getUserUnprotectedDomainsJson(userUnprotectedDomains) cachedUserUnprotectedDomains.addAll(userUnprotectedDomains) @@ -155,7 +142,7 @@ class RealContentScopeScripts @Inject constructor( private fun cacheUserUnprotectedTemporaryExceptions(unprotectedTemporaryExceptions: List) { cachedUnprotectTemporaryExceptions.clear() if (unprotectedTemporaryExceptions.isEmpty()) { - cachedUnprotectTemporaryExceptionsJson = emptyJsonList + cachedUnprotectTemporaryExceptionsJson = EMPTY_JSON_LIST } else { cachedUnprotectTemporaryExceptionsJson = getUnprotectedTemporaryJson(unprotectedTemporaryExceptions) cachedUnprotectTemporaryExceptions.addAll(unprotectedTemporaryExceptions) @@ -165,11 +152,12 @@ class RealContentScopeScripts @Inject constructor( private fun cacheContentScopeJS() { val contentScopeJS = contentScopeJSReader.getContentScopeJS() - cachedContentScopeJS = contentScopeJS - .replace(contentScope, cachedContentScopeJson) - .replace(userUnprotectedDomains, cachedUserUnprotectedDomainsJson) - .replace(userPreferences, cachedUserPreferencesJson) - .replace(messagingParameters, "${getSecretKeyValuePair()},${getCallbackKeyValuePair()},${getInterfaceKeyValuePair()}") + cachedContentScopeJS = + contentScopeJS + .replace(CONTENT_SCOPE, cachedContentScopeJson) + .replace(USER_UNPROTECTED_DOMAINS, cachedUserUnprotectedDomainsJson) + .replace(USER_PREFERENCES, cachedUserPreferencesJson) + .replace(MESSAGING_PARAMETERS, "${getSecretKeyValuePair()},${getCallbackKeyValuePair()},${getInterfaceKeyValuePair()}") } private fun getUserUnprotectedDomainsJson(userUnprotectedDomains: List): String { @@ -192,8 +180,9 @@ class RealContentScopeScripts @Inject constructor( activeExperiments: List, ): String { val experiments = getExperimentsKeyValuePair(activeExperiments) - val defaultParameters = "${getVersionNumberKeyValuePair()},${getPlatformKeyValuePair()},${getLanguageKeyValuePair()}," + - "${getSessionKeyValuePair()},${getDesktopModeKeyValuePair(isDesktopMode ?: false)},$messagingParameters" + val defaultParameters = + "${getVersionNumberKeyValuePair()},${getPlatformKeyValuePair()},${getLanguageKeyValuePair()}," + + "${getSessionKeyValuePair()},${getDesktopModeKeyValuePair(isDesktopMode ?: false)},$MESSAGING_PARAMETERS" if (userPreferences.isEmpty()) { return "{$experiments,$defaultParameters}" } @@ -201,10 +190,15 @@ class RealContentScopeScripts @Inject constructor( } private fun getVersionNumberKeyValuePair() = "\"versionNumber\":${appBuildConfig.versionCode}" + private fun getPlatformKeyValuePair() = "\"platform\":{\"name\":\"android\",\"internal\":${appBuildConfig.isInternalBuild()}}" + private fun getLanguageKeyValuePair() = "\"locale\":\"${Locale.getDefault().language}\"" + private fun getDesktopModeKeyValuePair(isDesktopMode: Boolean) = "\"desktopModeEnabled\":$isDesktopMode" + private fun getSessionKeyValuePair() = "\"sessionKey\":\"${fingerprintProtectionManager.getSeed()}\"" + private fun getExperimentsKeyValuePair(activeExperiments: List): String { return runBlocking { val type = Types.newParameterizedType(List::class.java, Experiment::class.java) @@ -224,21 +218,23 @@ class RealContentScopeScripts @Inject constructor( } } - private fun getContentScopeJson(config: String, unprotectedTemporaryExceptions: List): String = ( - "{\"features\":{$config},\"unprotectedTemporary\":${getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)}}" - ) + private fun getContentScopeJson( + config: String, + unprotectedTemporaryExceptions: List, + ): String = + ( + "{\"features\":{$config},\"unprotectedTemporary\":${getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)}}" + ) companion object { - const val emptyJsonList = "[]" - const val emptyJson = "{}" - const val contentScope = "\$CONTENT_SCOPE$" - const val userUnprotectedDomains = "\$USER_UNPROTECTED_DOMAINS$" - const val userPreferences = "\$USER_PREFERENCES$" - const val messagingParameters = "\$ANDROID_MESSAGING_PARAMETERS$" - - private fun getSecret(): String { - return UUID.randomUUID().toString().replace("-", "") - } + const val EMPTY_JSON_LIST = "[]" + const val EMPTY_JSON = "{}" + const val CONTENT_SCOPE = "\$CONTENT_SCOPE$" + const val USER_UNPROTECTED_DOMAINS = "\$USER_UNPROTECTED_DOMAINS$" + const val USER_PREFERENCES = "\$USER_PREFERENCES$" + const val MESSAGING_PARAMETERS = "\$ANDROID_MESSAGING_PARAMETERS$" + + private fun getSecret(): String = UUID.randomUUID().toString().replace("-", "") } } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt index 726e7e64030e..32a600558e16 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt @@ -22,7 +22,7 @@ import androidx.core.net.toUri import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin -import com.duckduckgo.contentscopescripts.impl.CoreContentScopeScripts +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.JsMessage @@ -49,7 +49,6 @@ class ContentScopeScriptsJsMessaging @Inject constructor( private val coreContentScopeScripts: CoreContentScopeScripts, private val handlers: PluginPoint, ) : JsMessaging { - private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() private lateinit var webView: WebView @@ -61,13 +60,17 @@ class ContentScopeScriptsJsMessaging @Inject constructor( override val allowedDomains: List = emptyList() @JavascriptInterface - override fun process(message: String, secret: String) { + override fun process( + message: String, + secret: String, + ) { try { val adapter = moshi.adapter(JsMessage::class.java) val jsMessage = adapter.fromJson(message) - val domain = runBlocking(dispatcherProvider.main()) { - webView.url?.toUri()?.host - } + val domain = + runBlocking(dispatcherProvider.main()) { + webView.url?.toUri()?.host + } jsMessage?.let { if (this.secret == secret && context == jsMessage.context && (allowedDomains.isEmpty() || allowedDomains.contains(domain))) { if (jsMessage.method == "addDebugFlag") { @@ -79,10 +82,13 @@ class ContentScopeScriptsJsMessaging @Inject constructor( data = jsMessage.params, ) } - handlers.getPlugins().map { it.getJsMessageHandler() }.firstOrNull { - it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName && - (it.allowedDomains.isEmpty() || it.allowedDomains.contains(domain)) - }?.process(jsMessage, this, jsMessageCallback) + handlers + .getPlugins() + .map { it.getJsMessageHandler() } + .firstOrNull { + it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName && + (it.allowedDomains.isEmpty() || it.allowedDomains.contains(domain)) + }?.process(jsMessage, this, jsMessageCallback) } } } catch (e: Exception) { @@ -90,7 +96,10 @@ class ContentScopeScriptsJsMessaging @Inject constructor( } } - override fun register(webView: WebView, jsMessageCallback: JsMessageCallback?) { + override fun register( + webView: WebView, + jsMessageCallback: JsMessageCallback?, + ) { if (jsMessageCallback == null) throw Exception("Callback cannot be null") this.webView = webView this.jsMessageCallback = jsMessageCallback @@ -98,25 +107,27 @@ class ContentScopeScriptsJsMessaging @Inject constructor( } override fun sendSubscriptionEvent(subscriptionEventData: SubscriptionEventData) { - val subscriptionEvent = SubscriptionEvent( - context, - subscriptionEventData.featureName, - subscriptionEventData.subscriptionName, - subscriptionEventData.params, - ) + val subscriptionEvent = + SubscriptionEvent( + context, + subscriptionEventData.featureName, + subscriptionEventData.subscriptionName, + subscriptionEventData.params, + ) if (::webView.isInitialized) { jsMessageHelper.sendSubscriptionEvent(subscriptionEvent, callbackName, secret, webView) } } override fun onResponse(response: JsCallbackData) { - val jsResponse = JsRequestResponse.Success( - context = context, - featureName = response.featureName, - method = response.method, - id = response.id, - result = response.params, - ) + val jsResponse = + JsRequestResponse.Success( + context = context, + featureName = response.featureName, + method = response.method, + id = response.id, + result = response.params, + ) jsMessageHelper.sendJsResponse(jsResponse, callbackName, secret, webView) } } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPlugin.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPlugin.kt index ce68e8f62fc5..b6c1f60a6bab 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPlugin.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPlugin.kt @@ -18,7 +18,7 @@ package com.duckduckgo.contentscopescripts.impl.messaging import android.annotation.SuppressLint import android.webkit.WebView -import com.duckduckgo.contentscopescripts.impl.CoreContentScopeScripts +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import com.duckduckgo.contentscopescripts.impl.WebViewCompatContentScopeScripts import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.js.messaging.api.JsMessageHelper diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPluginTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPluginTest.kt index 7454005b868a..01a81d3faa2d 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPluginTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsJsInjectorPluginTest.kt @@ -1,7 +1,7 @@ package com.duckduckgo.contentscopescripts.impl import android.webkit.WebView -import org.junit.Assert.* +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -10,10 +10,8 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever -import java.util.* class ContentScopeScriptsJsInjectorPluginTest { - private val mockCoreContentScopeScripts: CoreContentScopeScripts = mock() private val mockWebView: WebView = mock() diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt index f677f044667c..64426a217ac1 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt @@ -22,6 +22,7 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.BuildFlavor import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.FeatureException import com.duckduckgo.feature.toggles.api.Toggle diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt index 4351a4e3534a..80405e46fabf 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessagingTest.kt @@ -21,7 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin -import com.duckduckgo.contentscopescripts.impl.CoreContentScopeScripts +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import com.duckduckgo.js.messaging.api.JsMessage import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessageHandler @@ -51,13 +51,11 @@ class ContentScopeScriptsJsMessagingTest { private lateinit var contentScopeScriptsJsMessaging: ContentScopeScriptsJsMessaging private class FakePluginPoint : PluginPoint { - override fun getPlugins(): Collection { - return listOf(FakePlugin()) - } + override fun getPlugins(): Collection = listOf(FakePlugin()) inner class FakePlugin : ContentScopeJsMessageHandlersPlugin { - override fun getJsMessageHandler(): JsMessageHandler { - return object : JsMessageHandler { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { override fun process( jsMessage: JsMessage, jsMessaging: JsMessaging, @@ -70,7 +68,6 @@ class ContentScopeScriptsJsMessagingTest { override val featureName: String = "webCompat" override val methods: List = listOf("webShare", "permissionsQuery") } - } } } @@ -79,61 +76,69 @@ class ContentScopeScriptsJsMessagingTest { whenever(coreContentScopeScripts.secret).thenReturn("secret") whenever(coreContentScopeScripts.javascriptInterface).thenReturn("javascriptInterface") whenever(coreContentScopeScripts.callbackName).thenReturn("callbackName") - contentScopeScriptsJsMessaging = ContentScopeScriptsJsMessaging( - jsMessageHelper, - coroutineRule.testDispatcherProvider, - coreContentScopeScripts, - handlers, - ) + contentScopeScriptsJsMessaging = + ContentScopeScriptsJsMessaging( + jsMessageHelper, + coroutineRule.testDispatcherProvider, + coreContentScopeScripts, + handlers, + ) } @Test - fun `when process and message can be handled then execute callback`() = runTest { - givenInterfaceIsRegistered() + fun `when process and message can be handled then execute callback`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(1, callback.counter) - } + assertEquals(1, callback.counter) + } @Test - fun `when processing unknown message do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when processing unknown message do nothing`() = + runTest { + givenInterfaceIsRegistered() - contentScopeScriptsJsMessaging.process("", contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process("", contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `when processing unknown secret do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when processing unknown secret do nothing`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, "test") + contentScopeScriptsJsMessaging.process(message, "test") - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `if interface is not registered do nothing`() = runTest { - whenever(mockWebView.url).thenReturn("https://example.com") + fun `if interface is not registered do nothing`() = + runTest { + whenever(mockWebView.url).thenReturn("https://example.com") - val message = """ - {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test fun `when registering interface then add javascript interface is called`() { @@ -143,77 +148,94 @@ class ContentScopeScriptsJsMessagingTest { } @Test - fun `when url is not allowed do nothing`() = runTest { - givenInterfaceIsRegistered() - whenever(mockWebView.url).thenReturn("https://nowAllowed.com") + fun `when url is not allowed do nothing`() = + runTest { + givenInterfaceIsRegistered() + whenever(mockWebView.url).thenReturn("https://nowAllowed.com") - val message = """ - {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"webCompat","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `when feature does not match do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when feature does not match do nothing`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"test","id":"myId","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"test","id":"myId","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `when id does not exist do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when id does not exist do nothing`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","webCompat":"test","method":"webShare","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","webCompat":"test","method":"webShare","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } @Test - fun `when processing addDebugFlag message with valid secret and domain then process message`() = runTest { - givenInterfaceIsRegistered() + fun `when processing addDebugFlag message with valid secret and domain then process message`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"debugFeature","id":"debugId","method":"addDebugFlag","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"debugFeature","id":"debugId","method":"addDebugFlag","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) + contentScopeScriptsJsMessaging.process(message, contentScopeScriptsJsMessaging.secret) - assertEquals(1, callback.counter) - } + assertEquals(1, callback.counter) + } @Test - fun `when processing addDebugFlag message with wrong secret then do nothing`() = runTest { - givenInterfaceIsRegistered() + fun `when processing addDebugFlag message with wrong secret then do nothing`() = + runTest { + givenInterfaceIsRegistered() - val message = """ - {"context":"contentScopeScripts","featureName":"debugFeature","id":"debugId","method":"addDebugFlag","params":{}} - """.trimIndent() + val message = + """ + {"context":"contentScopeScripts","featureName":"debugFeature","id":"debugId","method":"addDebugFlag","params":{}} + """.trimIndent() - contentScopeScriptsJsMessaging.process(message, "wrongSecret") + contentScopeScriptsJsMessaging.process(message, "wrongSecret") - assertEquals(0, callback.counter) - } + assertEquals(0, callback.counter) + } - private val callback = object : JsMessageCallback() { - var counter = 0 - override fun process(featureName: String, method: String, id: String?, data: JSONObject?) { - counter++ + private val callback = + object : JsMessageCallback() { + var counter = 0 + + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + counter++ + } } - } private fun givenInterfaceIsRegistered() { contentScopeScriptsJsMessaging.register(mockWebView, callback) diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPluginTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPluginTest.kt index d6313ad32772..9cbf38cfffcf 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPluginTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsPostMessageWrapperPluginTest.kt @@ -1,7 +1,7 @@ package com.duckduckgo.contentscopescripts.impl.messaging import android.webkit.WebView -import com.duckduckgo.contentscopescripts.impl.CoreContentScopeScripts +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts import com.duckduckgo.contentscopescripts.impl.WebViewCompatContentScopeScripts import com.duckduckgo.js.messaging.api.JsMessageHelper import com.duckduckgo.js.messaging.api.SubscriptionEvent diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt index 827d4496db6c..6e8a5c302d0e 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsActivity.kt @@ -49,6 +49,8 @@ import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.discovery.InputScreen import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_SETTINGS_DISPLAYED import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.ViewState import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.settings.api.SettingsPageFeature +import com.duckduckgo.settings.api.SettingsWebViewScreenWithParams import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import javax.inject.Inject @@ -57,7 +59,6 @@ import com.duckduckgo.mobile.android.R as CommonR @InjectWith(ActivityScope::class) @ContributeToActivityStarter(DuckChatSettingsNoParams::class, screenName = "duckai.settings") class DuckChatSettingsActivity : DuckDuckGoActivity() { - private val viewModel: DuckChatSettingsViewModel by bindViewModel() private val binding: ActivityDuckChatSettingsBinding by viewBinding() @@ -79,6 +80,9 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var appTheme: AppTheme + @Inject + lateinit var settingsPageFeature: SettingsPageFeature + @Inject lateinit var inputScreenDiscoveryFunnel: InputScreenDiscoveryFunnel @@ -119,11 +123,13 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { binding.showDuckChatSearchSettingsLink.setSecondaryText(getString(R.string.duck_chat_assist_settings_description_rebranding)) // align content with the main Duck.ai toggle's text - val offset = resources.getDimensionPixelSize(CommonR.dimen.listItemImageContainerSize) + - resources.getDimensionPixelSize(CommonR.dimen.keyline_4) + val offset = + resources.getDimensionPixelSize(CommonR.dimen.listItemImageContainerSize) + + resources.getDimensionPixelSize(CommonR.dimen.keyline_4) val orientation = resources.configuration.orientation binding.duckAiInputScreenToggleContainer.updatePadding( - left = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + left = + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { 0 } else { offset @@ -148,17 +154,20 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { } binding.duckChatSettingsText.addClickableSpan( - textSequence = if (viewState.isRebrandingAiFeaturesEnabled) { + textSequence = + if (viewState.isRebrandingAiFeaturesEnabled) { getText(R.string.duck_chat_settings_activity_description_rebranding) } else { getText(R.string.duck_chat_settings_activity_description) }, - spans = listOf( - "learn_more_link" to object : DuckDuckGoClickableSpan() { - override fun onClick(widget: View) { - viewModel.duckChatLearnMoreClicked() - } - }, + spans = + listOf( + "learn_more_link" to + object : DuckDuckGoClickableSpan() { + override fun onClick(widget: View) { + viewModel.duckChatLearnMoreClicked() + } + }, ), ) @@ -171,12 +180,14 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { binding.duckAiInputScreenDescription.isVisible = viewState.shouldShowInputScreenToggle binding.duckAiInputScreenDescription.addClickableSpan( textSequence = getText(R.string.input_screen_user_pref_description), - spans = listOf( - "share_feedback" to object : DuckDuckGoClickableSpan() { - override fun onClick(widget: View) { - viewModel.duckAiInputScreenShareFeedbackClicked() - } - }, + spans = + listOf( + "share_feedback" to + object : DuckDuckGoClickableSpan() { + override fun onClick(widget: View) { + viewModel.duckAiInputScreenShareFeedbackClicked() + } + }, ), ) @@ -199,13 +210,23 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { private fun processCommand(command: DuckChatSettingsViewModel.Command) { when (command) { is DuckChatSettingsViewModel.Command.OpenLink -> { - globalActivityStarter.start( - this, - WebViewActivityWithParams( - url = command.link, - screenTitle = getString(R.string.duck_chat_title), - ), - ) + if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + globalActivityStarter.start( + this, + SettingsWebViewScreenWithParams( + url = command.link, + screenTitle = getString(command.titleRes), + ), + ) + } else { + globalActivityStarter.start( + this, + WebViewActivityWithParams( + url = command.link, + screenTitle = getString(R.string.duck_chat_title), + ), + ) + } } is DuckChatSettingsViewModel.Command.OpenLinkInNewTab -> { startActivity(browserNav.openInNewTab(this@DuckChatSettingsActivity, command.link)) @@ -234,31 +255,42 @@ class DuckChatSettingsActivity : DuckDuckGoActivity() { duckAiInputScreenToggleWithAiCheck.setImageDrawable(ContextCompat.getDrawable(context, withAi.checkRes)) } - private sealed class InputScreenToggleButton(isActive: Boolean) { + private sealed class InputScreenToggleButton( + isActive: Boolean, + ) { abstract val imageRes: Int - val checkRes: Int = if (isActive) { - CommonR.drawable.ic_check_accent_24 - } else { - CommonR.drawable.ic_shape_circle_disabled_24 - } - - class WithoutAi(isActive: Boolean, isLightMode: Boolean) : InputScreenToggleButton(isActive) { - override val imageRes: Int = when { - isActive && isLightMode -> R.drawable.searchbox_withoutai_active - isActive && !isLightMode -> R.drawable.searchbox_withoutai_active_dark - !isActive && isLightMode -> R.drawable.searchbox_withoutai_inactive - else -> R.drawable.searchbox_withoutai_inactive_dark + val checkRes: Int = + if (isActive) { + CommonR.drawable.ic_check_accent_24 + } else { + CommonR.drawable.ic_shape_circle_disabled_24 } + + class WithoutAi( + isActive: Boolean, + isLightMode: Boolean, + ) : InputScreenToggleButton(isActive) { + override val imageRes: Int = + when { + isActive && isLightMode -> R.drawable.searchbox_withoutai_active + isActive && !isLightMode -> R.drawable.searchbox_withoutai_active_dark + !isActive && isLightMode -> R.drawable.searchbox_withoutai_inactive + else -> R.drawable.searchbox_withoutai_inactive_dark + } } - class WithAi(isActive: Boolean, isLightMode: Boolean) : InputScreenToggleButton(isActive) { - override val imageRes: Int = when { - isActive && isLightMode -> R.drawable.searchbox_withai_active - isActive && !isLightMode -> R.drawable.searchbox_withai_active_dark - !isActive && isLightMode -> R.drawable.searchbox_withai_inactive - else -> R.drawable.searchbox_withai_inactive_dark - } + class WithAi( + isActive: Boolean, + isLightMode: Boolean, + ) : InputScreenToggleButton(isActive) { + override val imageRes: Int = + when { + isActive && isLightMode -> R.drawable.searchbox_withai_active + isActive && !isLightMode -> R.drawable.searchbox_withai_active_dark + !isActive && isLightMode -> R.drawable.searchbox_withai_inactive + else -> R.drawable.searchbox_withai_inactive_dark + } } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt index 7b9cda37650e..b39613be4df1 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModel.kt @@ -16,17 +16,20 @@ package com.duckduckgo.duckchat.impl.ui.settings +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.duckchat.impl.DuckChatInternal +import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.discovery.InputScreenDiscoveryFunnel import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenLink import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenLinkInNewTab import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.OpenShortcutSettings +import com.duckduckgo.settings.api.SettingsConstants import com.duckduckgo.settings.api.SettingsPageFeature import com.duckduckgo.subscriptions.api.SubscriptionRebrandingFeatureToggle import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST @@ -74,6 +77,7 @@ class DuckChatSettingsViewModel @Inject constructor( sealed class Command { data class OpenLink( val link: String, + @StringRes val titleRes: Int, ) : Command() data class OpenLinkInNewTab( @@ -120,19 +124,22 @@ class DuckChatSettingsViewModel @Inject constructor( fun duckChatLearnMoreClicked() { viewModelScope.launch { - commandChannel.send(OpenLink(DUCK_CHAT_LEARN_MORE_LINK)) + commandChannel.send(OpenLink(DUCK_CHAT_LEARN_MORE_LINK, R.string.duck_chat_title)) } } fun duckChatSearchAISettingsClicked() { viewModelScope.launch { - val settingsLink = - if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM - } else { - DUCK_CHAT_SEARCH_AI_SETTINGS_LINK - } - commandChannel.send(OpenLinkInNewTab(settingsLink)) + if (settingsPageFeature.saveAndExitSerpSettings().isEnabled()) { + commandChannel.send( + OpenLink( + DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM, + R.string.duck_chat_search_assist_settings_title, + ), + ) + } else { + commandChannel.send(OpenLinkInNewTab(DUCK_CHAT_SEARCH_AI_SETTINGS_LINK)) + } pixel.fire(DuckChatPixelName.DUCK_CHAT_SEARCH_ASSIST_SETTINGS_BUTTON_CLICKED) } } @@ -168,6 +175,8 @@ class DuckChatSettingsViewModel @Inject constructor( companion object { const val DUCK_CHAT_LEARN_MORE_LINK = "https://duckduckgo.com/duckduckgo-help-pages/aichat/" const val DUCK_CHAT_SEARCH_AI_SETTINGS_LINK = "https://duckduckgo.com/settings?ko=-1#aifeatures" - const val DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM = "https://duckduckgo.com/settings?ko=-1&return=aiFeatures#aifeatures" + const val DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM = + "https://duckduckgo.com/settings?ko=-1&return=" + + "${SettingsConstants.ID_AI_FEATURES}#aifeatures" } } 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..973308e87be6 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml @@ -0,0 +1,19 @@ + + + + Search Assist Settings + \ No newline at end of file diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt index ee57b0e7e124..461c217e3c0c 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/settings/DuckChatSettingsViewModelTest.kt @@ -20,6 +20,7 @@ import app.cash.turbine.test import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.impl.DuckChatInternal +import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.discovery.InputScreenDiscoveryFunnel import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.Command.LaunchFeedback @@ -239,6 +240,27 @@ class DuckChatSettingsViewModelTest { verify(mockPixel).fire(DuckChatPixelName.DUCK_CHAT_SEARCH_ASSIST_SETTINGS_BUTTON_CLICKED) } + @Test + fun whenDuckChatSearchAISettingsClickedAndSaveAndExitEnabledThenOpenSettingsLinkWithReturnParamEmitted() = + runTest { + @Suppress("DenyListedApi") + settingsPageFeature.saveAndExitSerpSettings().setRawStoredState(State(enable = true)) + + testee.duckChatSearchAISettingsClicked() + + testee.commands.test { + val command = awaitItem() + assertTrue(command is OpenLink) + command as OpenLink + assertEquals( + DuckChatSettingsViewModel.DUCK_CHAT_SEARCH_AI_SETTINGS_LINK_WITH_RETURN_PARAM, + command.link, + ) + assertEquals(R.string.duck_chat_search_assist_settings_title, command.titleRes) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `when onDuckChatUserEnabledToggled true then enabled pixel fired`() = runTest { diff --git a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt new file mode 100644 index 000000000000..8ec9b7fc2869 --- /dev/null +++ b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsConstants.kt @@ -0,0 +1,22 @@ +/* + * 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.settings.api + +object SettingsConstants { + const val ID_AI_FEATURES = "aiFeatures" + const val ID_PRIVATE_SEARCH = "privateSearch" +} diff --git a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsWebViewScreenWithParams.kt b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsWebViewScreenWithParams.kt new file mode 100644 index 000000000000..dda87bd1e166 --- /dev/null +++ b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/SettingsWebViewScreenWithParams.kt @@ -0,0 +1,24 @@ +/* + * 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.settings.api + +import com.duckduckgo.navigation.api.GlobalActivityStarter + +data class SettingsWebViewScreenWithParams( + val url: String, + val screenTitle: String, +) : GlobalActivityStarter.ActivityParams diff --git a/settings/settings-impl/build.gradle b/settings/settings-impl/build.gradle index 732fe32f156d..ce3e56ae2b10 100644 --- a/settings/settings-impl/build.gradle +++ b/settings/settings-impl/build.gradle @@ -27,19 +27,48 @@ android { anvil { generateDaggerFactories = true // default is false } + testOptions { + unitTests { + includeAndroidResources = true + } + } } dependencies { anvil project(path: ':anvil-compiler') - implementation project(path: ':anvil-annotations') + implementation project(':anvil-annotations') + + implementation project(':di') + implementation project(':browser-api') + implementation project(':design-system') + implementation project(':navigation-api') + implementation project(':user-agent-api') + implementation project(':settings-api') + implementation project(':common-utils') + implementation project(':js-messaging-api') + implementation project(':statistics-api') + implementation project(':content-scope-scripts-api') - implementation project(path: ':di') - implementation project(path: ':settings-api') - implementation project(path: ':common-utils') + implementation "com.squareup.logcat:logcat:_" + implementation AndroidX.appCompat implementation AndroidX.core.ktx + implementation AndroidX.webkit + implementation KotlinX.coroutines.android // Dagger implementation Google.dagger + + testImplementation Testing.junit4 + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation project(path: ':common-test') + testImplementation CashApp.turbine + testImplementation(KotlinX.coroutines.test) { + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + testImplementation "androidx.test.ext:junit-ktx:_" + testImplementation AndroidX.lifecycle.runtime.testing + testImplementation AndroidX.archCore.testing + testImplementation Testing.robolectric } \ No newline at end of file diff --git a/settings/settings-impl/src/main/AndroidManifest.xml b/settings/settings-impl/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..6132911b63d1 --- /dev/null +++ b/settings/settings-impl/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt new file mode 100644 index 000000000000..bab039a4a00d --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewActivity.kt @@ -0,0 +1,182 @@ +/* + * 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. + */ + +package com.duckduckgo.settings.impl + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.MenuItem +import android.webkit.WebSettings +import androidx.activity.OnBackPressedCallback +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.app.statistics.pixels.Pixel +import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.contentscopescripts.api.CoreContentScopeScripts +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.settings.api.SettingsWebViewScreenWithParams +import com.duckduckgo.settings.impl.databinding.ActivitySettingsWebviewBinding +import com.duckduckgo.user.agent.api.UserAgentProvider +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Named + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(SettingsWebViewScreenWithParams::class) +class SettingsWebViewActivity : DuckDuckGoActivity() { + @Inject + lateinit var userAgentProvider: UserAgentProvider + + @Inject + lateinit var webViewCompat: WebViewCompatWrapper + + @Inject + lateinit var pixel: Pixel + + private val viewModel: SettingsWebViewViewModel by bindViewModel() + + @Inject + lateinit var css: CoreContentScopeScripts + + @Inject + @Named("ContentScopeScripts") + lateinit var contentScopeScripts: JsMessaging + + private val binding: ActivitySettingsWebviewBinding by viewBinding() + + private val toolbar + get() = binding.includeToolbar.toolbar + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val params = intent.getActivityParams(SettingsWebViewScreenWithParams::class.java) + val url = params?.url + title = params?.screenTitle.orEmpty() + + setContentView(binding.root) + setupToolbar(toolbar) + + lifecycleScope.launch { + setupWebView() + setupCollectors() + setupBackPressedDispatcher() + + viewModel.onStart(url) + } + } + + private fun setupBackPressedDispatcher() { + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.settingsWebView.canGoBack()) { + binding.settingsWebView.goBack() + } else { + exit() + } + } + }, + ) + } + + private fun setupCollectors() { + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun processCommand(command: SettingsWebViewViewModel.Command) { + when (command) { + is SettingsWebViewViewModel.Command.LoadUrl -> binding.settingsWebView.loadUrl(command.url) + SettingsWebViewViewModel.Command.Exit -> exit() + } + } + + private fun exit() { + binding.settingsWebView.stopLoading() + binding.settingsWebView.removeJavascriptInterface(contentScopeScripts.context) + binding.root.removeView(binding.settingsWebView) + binding.settingsWebView.destroy() + + finish() + } + + @SuppressLint("SetJavaScriptEnabled") + private suspend fun setupWebView() { + binding.settingsWebView.let { + contentScopeScripts.register( + it, + object : JsMessageCallback() { + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + viewModel.processJsCallbackMessage(featureName, method, id, data) + } + }, + ) + + webViewCompat.addDocumentStartJavaScript( + it, + "javascript:${css.getScript(false, emptyList())}", + setOf( + "https://duckduckgo.com", // exact origin + "https://*.duckduckgo.com", // any subdomain + ), + ) + + it.settings.apply { + userAgentString = userAgentProvider.userAgent() + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + databaseEnabled = false + setSupportZoom(true) + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } +} diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt new file mode 100644 index 000000000000..11771c4e8147 --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/SettingsWebViewViewModel.kt @@ -0,0 +1,98 @@ +/* + * 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.settings.impl + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.settings.api.SettingsConstants +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.FEATURE_SERP_SETTINGS +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.METHOD_OPEN_NATIVE_SETTINGS +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.PARAM_RETURN +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import logcat.LogPriority +import logcat.logcat +import org.json.JSONObject +import javax.inject.Inject + +@ContributesViewModel(ActivityScope::class) +class SettingsWebViewViewModel @Inject constructor( + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) + val commands = commandChannel.receiveAsFlow() + + sealed class Command { + data class LoadUrl( + val url: String, + ) : Command() + + data object Exit : Command() + } + + fun onStart(url: String?) { + if (url != null) { + sendCommand(Command.LoadUrl(url)) + } else { + logcat(LogPriority.ERROR) { "No URL provided to WebViewActivity" } + sendCommand(Command.Exit) + } + } + + fun processJsCallbackMessage( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + viewModelScope.launch(dispatcherProvider.io()) { + when (featureName) { + FEATURE_SERP_SETTINGS -> + when (method) { + METHOD_OPEN_NATIVE_SETTINGS -> { + val returnParam = data?.optString(PARAM_RETURN) + openNativeSettings(returnParam) + } + } + } + } + } + + private fun openNativeSettings(returnParam: String?) { + when (returnParam) { + SettingsConstants.ID_AI_FEATURES, + SettingsConstants.ID_PRIVATE_SEARCH, + -> { + sendCommand(Command.Exit) + } + else -> { + logcat(LogPriority.WARN) { "Unknown settings return value: $returnParam" } + } + } + } + + private fun sendCommand(command: Command) { + viewModelScope.launch { + commandChannel.send(command) + } + } +} diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt new file mode 100644 index 000000000000..d501f901f550 --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/messaging/SettingsContentScopeJsMessageHandler.kt @@ -0,0 +1,58 @@ +/* + * 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.settings.impl.messaging + +import com.duckduckgo.common.utils.AppUrl +import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessageCallback +import com.duckduckgo.js.messaging.api.JsMessageHandler +import com.duckduckgo.js.messaging.api.JsMessaging +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class SettingsContentScopeJsMessageHandler @Inject constructor() : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id ?: "", jsMessage.params) + } + + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + ) + + override val featureName: String = FEATURE_SERP_SETTINGS + override val methods: List = + listOf( + METHOD_OPEN_NATIVE_SETTINGS, + ) + } + + companion object { + internal const val FEATURE_SERP_SETTINGS = "serpSettings" + internal const val METHOD_OPEN_NATIVE_SETTINGS = "openNativeSettings" + internal const val PARAM_RETURN = "return" + } +} diff --git a/settings/settings-impl/src/main/res/layout/activity_settings_webview.xml b/settings/settings-impl/src/main/res/layout/activity_settings_webview.xml new file mode 100644 index 000000000000..08774b20b30d --- /dev/null +++ b/settings/settings-impl/src/main/res/layout/activity_settings_webview.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt b/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt new file mode 100644 index 000000000000..5f1e624e212b --- /dev/null +++ b/settings/settings-impl/src/test/java/com/duckduckgo/settings/impl/SettingsWebViewViewModelTest.kt @@ -0,0 +1,152 @@ +/* + * 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.settings.impl + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.settings.api.SettingsConstants.ID_AI_FEATURES +import com.duckduckgo.settings.api.SettingsConstants.ID_PRIVATE_SEARCH +import com.duckduckgo.settings.impl.SettingsWebViewViewModel.Command +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.FEATURE_SERP_SETTINGS +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.METHOD_OPEN_NATIVE_SETTINGS +import com.duckduckgo.settings.impl.messaging.SettingsContentScopeJsMessageHandler.Companion.PARAM_RETURN +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SettingsWebViewViewModelTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private lateinit var viewModel: SettingsWebViewViewModel + + @Before + fun setup() { + viewModel = SettingsWebViewViewModel(coroutineTestRule.testDispatcherProvider) + } + + @Test + fun whenOnStartWithUrlThenLoadUrlCommandEmitted() = runTest { + val testUrl = "https://example.com/settings" + viewModel.commands.test { + viewModel.onStart(testUrl) + + val command = awaitItem() + assertTrue(command is Command.LoadUrl) + assertEquals(testUrl, (command as Command.LoadUrl).url) + } + } + + @Test + fun whenOnStartWithNullUrlThenExitCommandEmitted() = runTest { + viewModel.commands.test { + viewModel.onStart(null) + + val command = awaitItem() + assertTrue(command is Command.Exit) + } + } + + @Test + fun whenProcessOpenNativeSettingsAiFeaturesReturnThenExitCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(PARAM_RETURN, ID_AI_FEATURES) + viewModel.processJsCallbackMessage( + featureName = FEATURE_SERP_SETTINGS, + method = METHOD_OPEN_NATIVE_SETTINGS, + id = null, + data = data, + ) + + val command = awaitItem() + assertTrue(command is Command.Exit) + } + } + + @Test + fun whenProcessOpenNativeSettingsPrivateSearchReturnThenExitCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(PARAM_RETURN, ID_PRIVATE_SEARCH) + viewModel.processJsCallbackMessage( + featureName = FEATURE_SERP_SETTINGS, + method = METHOD_OPEN_NATIVE_SETTINGS, + id = null, + data = data, + ) + + val command = awaitItem() + assertTrue(command is Command.Exit) + } + } + + @Test + fun whenProcessOpenNativeSettingsUnknownReturnThenNoCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(PARAM_RETURN, "unknownSection") + viewModel.processJsCallbackMessage( + featureName = FEATURE_SERP_SETTINGS, + method = METHOD_OPEN_NATIVE_SETTINGS, + id = null, + data = data, + ) + // Advance to run launched coroutines + advanceUntilIdle() + expectNoEvents() + } + } + + @Test + fun whenProcessDifferentFeatureNameThenNoCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(PARAM_RETURN, ID_AI_FEATURES) + viewModel.processJsCallbackMessage( + featureName = "otherFeature", + method = METHOD_OPEN_NATIVE_SETTINGS, + id = null, + data = data, + ) + advanceUntilIdle() + expectNoEvents() + } + } + + @Test + fun whenProcessDifferentMethodThenNoCommandEmitted() = runTest { + viewModel.commands.test { + val data = JSONObject().put(PARAM_RETURN, ID_AI_FEATURES) + viewModel.processJsCallbackMessage( + featureName = FEATURE_SERP_SETTINGS, + method = "someOtherMethod", + id = null, + data = data, + ) + advanceUntilIdle() + expectNoEvents() + } + } +}