Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions PixelDefinitions/pixels/definitions/browser_feature_state.json5
Original file line number Diff line number Diff line change
@@ -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"]
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> {
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"
}
}
Original file line number Diff line number Diff line change
@@ -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<DuckAiFeatureState>().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,
)
}
}
Loading