diff --git a/PixelDefinitions/pixels/definitions/browser_feature_state.json5 b/PixelDefinitions/pixels/definitions/browser_feature_state.json5 new file mode 100644 index 000000000000..4c627965d4f3 --- /dev/null +++ b/PixelDefinitions/pixels/definitions/browser_feature_state.json5 @@ -0,0 +1,64 @@ +{ + "m_browser_feature_daily_active_user_d": { + "description": "Sent once per day for an active user, carrying the current state of each browser feature that contributes a BrowserFeatureStateReporterPlugin. Fired from FeatureRetentionPixelSender when the search or app retention ATB refreshes.", + "owners": ["YoussefKeyrouz"], + "triggers": ["scheduled"], + "suffixes": ["form_factor"], + "parameters": [ + { + "key": "address_bar", + "type": "string", + "description": "Address bar layout the user has selected", + "enum": ["TOP", "BOTTOM", "SPLIT_TOP"] + }, + { + "key": "launch_screen", + "type": "string", + "description": "Screen the user has chosen to see when the app launches after inactivity", + "enum": ["new_tab_page", "last_opened_tab", "specific_page"] + }, + { + "key": "default_browser", + "type": "string", + "description": "Whether DuckDuckGo is the device's default browser", + "enum": ["0", "1"] + }, + { + "key": "email", + "type": "string", + "description": "Whether the user is signed in to Email Protection", + "enum": ["0", "1"] + }, + { + "key": "voice_search", + "type": "string", + "description": "Whether voice search is available to the user", + "enum": ["0", "1"] + }, + { + "key": "locale", + "type": "string", + "description": "Device locale as a sanitized language tag", + "examples": ["en-US", "de-DE"] + }, + { + "key": "duck_ai_input_mode", + "type": "string", + "description": "Whether the current configuration offers the Search/Duck.ai toggle in the address bar, or is search-only. Independent of which input surface renders it.", + "enum": ["search_and_duck_ai", "search_only"] + }, + { + "key": "duck_ai_native_input", + "type": "string", + "description": "Whether the Duck.ai native input field is the active input surface for this user", + "enum": ["0", "1"] + }, + { + "key": "duck_ai_contextual", + "type": "string", + "description": "Whether Duck.ai contextual mode is enabled for this user", + "enum": ["0", "1"] + } + ] + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckAiFeatureStateReporterPlugin.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckAiFeatureStateReporterPlugin.kt new file mode 100644 index 000000000000..c90d19d5cbcf --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckAiFeatureStateReporterPlugin.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 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.pixel + +import com.duckduckgo.app.statistics.api.BrowserFeatureStateReporterPlugin +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.toBinaryString +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckAiFeatureState +import com.duckduckgo.duckchat.api.nativeinput.NativeInputState +import com.duckduckgo.duckchat.impl.feature.DuckChatFeature +import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +/** + * Reports which Duck.ai capabilities are active for the user on the daily feature state pixel. + */ +@ContributesMultibinding(scope = AppScope::class, boundType = BrowserFeatureStateReporterPlugin::class) +@SingleInstanceIn(AppScope::class) +class DuckAiFeatureStateReporterPlugin @Inject constructor( + private val duckAiFeatureState: DuckAiFeatureState, + private val duckChatFeature: DuckChatFeature, + private val duckChatFeatureRepository: DuckChatFeatureRepository, + private val dispatcherProvider: DispatcherProvider, +) : BrowserFeatureStateReporterPlugin { + + override fun featureStateParams(): Map { + val inputMode = runBlocking(dispatcherProvider.io()) { + resolveInputModeCapability() + } + return mapOf( + DUCK_AI_INPUT_MODE to inputMode.pixelValue(), + DUCK_AI_NATIVE_INPUT to duckAiFeatureState.nativeInputFieldEnabled.value.toBinaryString(), + DUCK_AI_CONTEXTUAL to duckAiFeatureState.showContextualMode.value.toBinaryString(), + ) + } + + private suspend fun resolveInputModeCapability(): NativeInputState.InputMode { + return if ( + duckChatFeature.self().isEnabled() && + duckChatFeatureRepository.isDuckChatUserEnabled() && + duckChatFeatureRepository.isInputScreenUserSettingEnabled() + ) { + NativeInputState.InputMode.SEARCH_AND_DUCK_AI + } else { + NativeInputState.InputMode.SEARCH_ONLY + } + } + + private fun NativeInputState.InputMode.pixelValue(): String = when (this) { + NativeInputState.InputMode.SEARCH_AND_DUCK_AI -> "search_and_duck_ai" + NativeInputState.InputMode.SEARCH_ONLY -> "search_only" + } + + companion object { + const val DUCK_AI_INPUT_MODE = "duck_ai_input_mode" + const val DUCK_AI_NATIVE_INPUT = "duck_ai_native_input" + const val DUCK_AI_CONTEXTUAL = "duck_ai_contextual" + } +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/DuckAiFeatureStateReporterPluginTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/DuckAiFeatureStateReporterPluginTest.kt new file mode 100644 index 000000000000..0aa3d8ca0174 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/DuckAiFeatureStateReporterPluginTest.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 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.pixel + +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.duckchat.api.DuckAiFeatureState +import com.duckduckgo.duckchat.impl.feature.DuckChatFeature +import com.duckduckgo.duckchat.impl.pixel.DuckAiFeatureStateReporterPlugin.Companion.DUCK_AI_CONTEXTUAL +import com.duckduckgo.duckchat.impl.pixel.DuckAiFeatureStateReporterPlugin.Companion.DUCK_AI_INPUT_MODE +import com.duckduckgo.duckchat.impl.pixel.DuckAiFeatureStateReporterPlugin.Companion.DUCK_AI_NATIVE_INPUT +import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.mockito.kotlin.whenever + +class DuckAiFeatureStateReporterPluginTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private val nativeInputFieldEnabled = MutableStateFlow(false) + private val showContextualMode = MutableStateFlow(false) + private val duckAiFeatureState: DuckAiFeatureState = mock().also { + whenever(it.nativeInputFieldEnabled).thenReturn(nativeInputFieldEnabled) + whenever(it.showContextualMode).thenReturn(showContextualMode) + } + private val duckChatFeature = FakeFeatureToggleFactory.create(DuckChatFeature::class.java) + private val duckChatFeatureRepository: DuckChatFeatureRepository = mock { + onBlocking { isDuckChatUserEnabled() } doReturn false + onBlocking { isInputScreenUserSettingEnabled() } doReturn false + } + private val testee = DuckAiFeatureStateReporterPlugin( + duckAiFeatureState, + duckChatFeature, + duckChatFeatureRepository, + coroutineTestRule.testDispatcherProvider, + ) + + @Test + fun `when search only and nothing enabled then params report the off state`() { + assertEquals( + mapOf( + DUCK_AI_INPUT_MODE to "search_only", + DUCK_AI_NATIVE_INPUT to "0", + DUCK_AI_CONTEXTUAL to "0", + ), + testee.featureStateParams(), + ) + } + + @Test + fun `when everything enabled then params report the on state`() { + duckChatFeature.self().setRawStoredState(State(enable = true)) + duckChatFeatureRepository.stub { + onBlocking { isDuckChatUserEnabled() } doReturn true + onBlocking { isInputScreenUserSettingEnabled() } doReturn true + } + nativeInputFieldEnabled.value = true + showContextualMode.value = true + + assertEquals( + mapOf( + DUCK_AI_INPUT_MODE to "search_and_duck_ai", + DUCK_AI_NATIVE_INPUT to "1", + DUCK_AI_CONTEXTUAL to "1", + ), + testee.featureStateParams(), + ) + } + + @Test + fun `when mode is search and duck ai then the mode param says so`() { + duckChatFeature.self().setRawStoredState(State(enable = true)) + duckChatFeatureRepository.stub { + onBlocking { isDuckChatUserEnabled() } doReturn true + onBlocking { isInputScreenUserSettingEnabled() } doReturn true + } + + val params = testee.featureStateParams() + + assertEquals("search_and_duck_ai", params[DUCK_AI_INPUT_MODE]) + assertEquals("0", params[DUCK_AI_NATIVE_INPUT]) + assertEquals("0", params[DUCK_AI_CONTEXTUAL]) + } + + @Test + fun `when state changes then params reflect the latest values`() { + duckChatFeature.self().setRawStoredState(State(enable = true)) + duckChatFeatureRepository.stub { + onBlocking { isDuckChatUserEnabled() } doReturn true + onBlocking { isInputScreenUserSettingEnabled() } doReturn true + } + assertEquals("search_and_duck_ai", testee.featureStateParams()[DUCK_AI_INPUT_MODE]) + + duckChatFeature.self().setRawStoredState(State(enable = false)) + assertEquals("search_only", testee.featureStateParams()[DUCK_AI_INPUT_MODE]) + } + + @Test + fun `when the input surface changes the mode is unaffected`() { + duckChatFeature.self().setRawStoredState(State(enable = true)) + duckChatFeatureRepository.stub { + onBlocking { isDuckChatUserEnabled() } doReturn true + onBlocking { isInputScreenUserSettingEnabled() } doReturn true + } + nativeInputFieldEnabled.value = true + + val params = testee.featureStateParams() + + assertEquals("search_and_duck_ai", params[DUCK_AI_INPUT_MODE]) + assertEquals("1", params[DUCK_AI_NATIVE_INPUT]) + } + + @Test + fun `params contain exactly the three Duck ai keys`() { + assertEquals( + setOf(DUCK_AI_INPUT_MODE, DUCK_AI_NATIVE_INPUT, DUCK_AI_CONTEXTUAL), + testee.featureStateParams().keys, + ) + } +}