Skip to content

Commit 5c0ef1e

Browse files
authored
Add new omnibar usage pixels (#6667)
Task/Issue URL: https://app.asana.com/1/137249556945/project/488551667048375/task/1211122549943330?focus=true ### Description Implements 8 pixels to measure Duck.ai discovery, usage patterns, and rollout safety for the experimental omnibar feature. ### Pixels Added **Experimental Omnibar (5 pixels):** `m_aichat_experimental_omnibar_shown` (daily) - Discovery measurement `m_aichat_experimental_omnibar_prompt_submitted` (daily + count) - AI chat usage `m_aichat_experimental_omnibar_query_submitted` (daily + count) - Search usage `m_aichat_experimental_omnibar_mode_switched` (count) - Mode switching behavior `m_aichat_experimental_omnibar_session_both_modes` (daily + count) - Seamless both-mode usage **Legacy Omnibar Baseline (3 pixels):** `m_aichat_legacy_omnibar_shown` (daily) - Baseline comparison `m_aichat_legacy_omnibar_query_submitted` (daily + count) - Baseline search usage `m_aichat_legacy_omnibar_aichat_button_pressed` (daily + count) - Baseline AI chat usage ### Steps to test this PR _Test Case 1: Legacy Omnibar Baseline_ - [x] Fresh install - [x] Focus legacy omnibar (keyboard appears) → `m_aichat_legacy_omnibar_shown` - [x] Search "cats" → `m_aichat_legacy_omnibar_query_submitted_daily` + `m_aichat_legacy_omnibar_query_submitted_count` - [x] Tap AI chat button in omnibar → `m_aichat_legacy_omnibar_aichat_button_pressed_daily` + `m_aichat_legacy_omnibar_aichat_button_pressed_count` _Test Case 2: Basic Discovery & Usage_ - [x] Enable "Search and Duck.ai” in “AI Features" - [x] Focus experimental omnibar (keyboard appears) → `m_aichat_experimental_omnibar_shown` - [x] Type "duck" and submit as search → `m_aichat_experimental_omnibar_query_submitted_daily` + `m_aichat_experimental_omnibar_query_submitted_count` - [x] Toggle to AI mode → `m_aichat_experimental_omnibar_mode_switched` - [x] Type "hi" and submit as AI prompt → `m_aichat_experimental_omnibar_prompt_submitted_daily` + `m_aichat_experimental_omnibar_prompt_submitted_count`, `m_aichat_experimental_omnibar_session_both_modes_daily` + `m_aichat_experimental_omnibar_session_both_modes_count` _Test Case 3: Session Reset After Backgrounding_ - [x] Complete Test Case 2, then background/foreground app - [x] Focus omnibar, search "test" → `m_aichat_experimental_omnibar_query_submitted_count` - [x] Toggle to AI mode → `m_aichat_experimental_omnibar_mode_switched` - [x] Submit "help" → `m_aichat_experimental_omnibar_prompt_submitted_count`, `m_aichat_experimental_omnibar_session_both_modes_count`
1 parent df476b5 commit 5c0ef1e

File tree

9 files changed

+505
-5
lines changed

9 files changed

+505
-5
lines changed

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature
204204
import com.duckduckgo.app.settings.db.SettingsDataStore
205205
import com.duckduckgo.app.statistics.pixels.Pixel
206206
import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter
207+
import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily
207208
import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator
208209
import com.duckduckgo.app.tabs.ui.TabSwitcherActivity
209210
import com.duckduckgo.app.widget.AddWidgetLauncher
@@ -289,11 +290,13 @@ import com.duckduckgo.downloads.api.DownloadConfirmationDialogListener
289290
import com.duckduckgo.downloads.api.DownloadsFileActions
290291
import com.duckduckgo.downloads.api.FileDownloader
291292
import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload
293+
import com.duckduckgo.duckchat.api.DuckAiFeatureState
292294
import com.duckduckgo.duckchat.api.DuckChat
293295
import com.duckduckgo.duckchat.api.inputscreen.BrowserAndInputScreenTransitionProvider
294296
import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityParams
295297
import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityResultCodes
296298
import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityResultParams
299+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName
297300
import com.duckduckgo.duckplayer.api.DuckPlayer
298301
import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams
299302
import com.duckduckgo.js.messaging.api.JsCallbackData
@@ -557,6 +560,9 @@ class BrowserTabFragment :
557560
@Inject
558561
lateinit var duckChat: DuckChat
559562

563+
@Inject
564+
lateinit var duckAiFeatureState: DuckAiFeatureState
565+
560566
@Inject
561567
lateinit var webViewCapabilityChecker: WebViewCapabilityChecker
562568

@@ -1182,6 +1188,8 @@ class BrowserTabFragment :
11821188
}
11831189

11841190
private fun onUserSubmittedText(text: String) {
1191+
pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_QUERY_SUBMITTED)
1192+
pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_QUERY_SUBMITTED_DAILY, type = Daily())
11851193
userEnteredQuery(text)
11861194
}
11871195

@@ -2904,6 +2912,10 @@ class BrowserTabFragment :
29042912
}
29052913

29062914
override fun onDuckChatButtonPressed() {
2915+
if (!duckAiFeatureState.showInputScreen.value) {
2916+
pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED)
2917+
pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_AICHAT_BUTTON_PRESSED_DAILY, type = Daily())
2918+
}
29072919
onOmnibarDuckChatPressed(omnibar.getText())
29082920
}
29092921
},
@@ -2963,6 +2975,7 @@ class BrowserTabFragment :
29632975
) {
29642976
viewModel.triggerAutocomplete(query, hasFocus, false)
29652977
if (hasFocus) {
2978+
pixel.fire(DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_SHOWN, type = Daily())
29662979
cancelPendingAutofillRequestsToChooseCredentials()
29672980
} else {
29682981
omnibar.omnibarTextInput.hideKeyboard()

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenFragment.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
132132
binding.actionNewLine.setOnClickListener {
133133
binding.inputModeWidget.printNewLine()
134134
}
135+
136+
viewModel.fireShownPixel()
135137
}
136138

137139
private fun configureObservers() {
@@ -204,6 +206,7 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) {
204206
}
205207
onSearchSelected = {
206208
binding.viewPager.setCurrentItem(0, true)
209+
viewModel.onSearchSelected()
207210
viewModel.onSearchInputTextChanged(binding.inputModeWidget.text)
208211
binding.ddgLogo.apply {
209212
setImageResource(com.duckduckgo.mobile.android.R.drawable.logo_full)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.inputscreen.ui.session
18+
19+
import androidx.lifecycle.LifecycleOwner
20+
import com.duckduckgo.app.di.AppCoroutineScope
21+
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
22+
import com.duckduckgo.di.scopes.AppScope
23+
import com.squareup.anvil.annotations.ContributesMultibinding
24+
import javax.inject.Inject
25+
import kotlinx.coroutines.CoroutineScope
26+
import kotlinx.coroutines.launch
27+
28+
@ContributesMultibinding(
29+
scope = AppScope::class,
30+
boundType = MainProcessLifecycleObserver::class,
31+
)
32+
class InputScreenSessionLifecycleObserver @Inject constructor(
33+
private val sessionStore: InputScreenSessionStore,
34+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
35+
) : MainProcessLifecycleObserver {
36+
37+
override fun onCreate(owner: LifecycleOwner) {
38+
appCoroutineScope.launch {
39+
sessionStore.resetSession()
40+
}
41+
}
42+
43+
override fun onStop(owner: LifecycleOwner) {
44+
appCoroutineScope.launch {
45+
sessionStore.resetSession()
46+
}
47+
}
48+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.inputscreen.ui.session
18+
19+
import android.content.Context
20+
import androidx.datastore.core.DataStore
21+
import androidx.datastore.preferences.core.Preferences
22+
import androidx.datastore.preferences.preferencesDataStore
23+
import com.duckduckgo.di.scopes.AppScope
24+
import com.squareup.anvil.annotations.ContributesTo
25+
import dagger.Module
26+
import dagger.Provides
27+
import javax.inject.Qualifier
28+
29+
private val Context.inputScreenSessionDataStore: DataStore<Preferences> by preferencesDataStore(
30+
name = "input_screen_session",
31+
)
32+
33+
@Qualifier
34+
annotation class InputScreenSession
35+
36+
@Module
37+
@ContributesTo(AppScope::class)
38+
object InputScreenSessionModule {
39+
@Provides
40+
@InputScreenSession
41+
fun provideInputScreenSessionDataStore(context: Context): DataStore<Preferences> {
42+
return context.inputScreenSessionDataStore
43+
}
44+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.inputscreen.ui.session
18+
19+
import androidx.datastore.core.DataStore
20+
import androidx.datastore.preferences.core.Preferences
21+
import androidx.datastore.preferences.core.booleanPreferencesKey
22+
import androidx.datastore.preferences.core.edit
23+
import com.duckduckgo.di.scopes.AppScope
24+
import com.duckduckgo.duckchat.impl.inputscreen.ui.session.RealInputScreenSessionStore.Keys.HAS_USED_CHAT_MODE_KEY
25+
import com.duckduckgo.duckchat.impl.inputscreen.ui.session.RealInputScreenSessionStore.Keys.HAS_USED_SEARCH_MODE_KEY
26+
import com.squareup.anvil.annotations.ContributesBinding
27+
import javax.inject.Inject
28+
import kotlinx.coroutines.flow.firstOrNull
29+
30+
interface InputScreenSessionStore {
31+
suspend fun hasUsedSearchMode(): Boolean
32+
suspend fun hasUsedChatMode(): Boolean
33+
34+
suspend fun setHasUsedSearchMode(used: Boolean)
35+
suspend fun setHasUsedChatMode(used: Boolean)
36+
37+
suspend fun resetSession()
38+
}
39+
40+
@ContributesBinding(AppScope::class)
41+
class RealInputScreenSessionStore @Inject constructor(
42+
@InputScreenSession private val dataStore: DataStore<Preferences>,
43+
) : InputScreenSessionStore {
44+
45+
private object Keys {
46+
val HAS_USED_SEARCH_MODE_KEY = booleanPreferencesKey("HAS_USED_SEARCH_MODE")
47+
val HAS_USED_CHAT_MODE_KEY = booleanPreferencesKey("HAS_USED_CHAT_MODE")
48+
}
49+
50+
override suspend fun hasUsedSearchMode(): Boolean {
51+
return dataStore.data.firstOrNull()?.let { it[HAS_USED_SEARCH_MODE_KEY] } ?: false
52+
}
53+
54+
override suspend fun hasUsedChatMode(): Boolean {
55+
return dataStore.data.firstOrNull()?.let { it[HAS_USED_CHAT_MODE_KEY] } ?: false
56+
}
57+
58+
override suspend fun setHasUsedSearchMode(used: Boolean) {
59+
dataStore.edit { preferences ->
60+
preferences[HAS_USED_SEARCH_MODE_KEY] = used
61+
}
62+
}
63+
64+
override suspend fun setHasUsedChatMode(used: Boolean) {
65+
dataStore.edit { preferences ->
66+
preferences[HAS_USED_CHAT_MODE_KEY] = used
67+
}
68+
}
69+
70+
override suspend fun resetSession() {
71+
dataStore.edit { preferences ->
72+
preferences[HAS_USED_SEARCH_MODE_KEY] = false
73+
preferences[HAS_USED_CHAT_MODE_KEY] = false
74+
}
75+
}
76+
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/viewmodel/InputScreenViewModel.kt

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import androidx.lifecycle.ViewModelProvider
2222
import androidx.lifecycle.viewModelScope
2323
import com.duckduckgo.app.browser.UriString.Companion.isWebUrl
2424
import com.duckduckgo.app.di.AppCoroutineScope
25+
import com.duckduckgo.app.statistics.pixels.Pixel
26+
import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily
2527
import com.duckduckgo.browser.api.autocomplete.AutoComplete
2628
import com.duckduckgo.browser.api.autocomplete.AutoComplete.AutoCompleteResult
2729
import com.duckduckgo.browser.api.autocomplete.AutoComplete.AutoCompleteSuggestion
@@ -41,11 +43,23 @@ import com.duckduckgo.duckchat.impl.inputscreen.ui.command.Command.SwitchToTab
4143
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.InputFieldCommand
4244
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.SearchCommand
4345
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.SearchCommand.ShowRemoveSearchSuggestionDialog
46+
import com.duckduckgo.duckchat.impl.inputscreen.ui.session.InputScreenSessionStore
4447
import com.duckduckgo.duckchat.impl.inputscreen.ui.state.AutoCompleteScrollState
4548
import com.duckduckgo.duckchat.impl.inputscreen.ui.state.InputFieldState
4649
import com.duckduckgo.duckchat.impl.inputscreen.ui.state.InputScreenVisibilityState
4750
import com.duckduckgo.duckchat.impl.inputscreen.ui.state.SubmitButtonIcon
4851
import com.duckduckgo.duckchat.impl.inputscreen.ui.state.SubmitButtonIconState
52+
import com.duckduckgo.duckchat.impl.inputscreen.ui.viewmodel.UserSelectedMode.CHAT
53+
import com.duckduckgo.duckchat.impl.inputscreen.ui.viewmodel.UserSelectedMode.NONE
54+
import com.duckduckgo.duckchat.impl.inputscreen.ui.viewmodel.UserSelectedMode.SEARCH
55+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_MODE_SWITCHED
56+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_PROMPT_SUBMITTED
57+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_PROMPT_SUBMITTED_DAILY
58+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_QUERY_SUBMITTED
59+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_QUERY_SUBMITTED_DAILY
60+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES
61+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES_DAILY
62+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SHOWN
4963
import com.duckduckgo.history.api.NavigationHistory
5064
import com.duckduckgo.voice.api.VoiceSearchAvailability
5165
import dagger.assisted.Assisted
@@ -82,6 +96,10 @@ import logcat.LogPriority.WARN
8296
import logcat.asLog
8397
import logcat.logcat
8498

99+
enum class UserSelectedMode {
100+
SEARCH, CHAT, NONE
101+
}
102+
85103
class InputScreenViewModel @AssistedInject constructor(
86104
@Assisted currentOmnibarText: String,
87105
private val autoComplete: AutoComplete,
@@ -90,13 +108,16 @@ class InputScreenViewModel @AssistedInject constructor(
90108
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
91109
private val voiceSearchAvailability: VoiceSearchAvailability,
92110
private val autoCompleteSettings: AutoCompleteSettings,
111+
private val pixel: Pixel,
112+
private val sessionStore: InputScreenSessionStore,
93113
) : ViewModel() {
94114

95115
private var hasUserSeenHistoryIAM = false
96116

97117
private val newTabPageHasContent = MutableStateFlow(false)
98118
private val voiceServiceAvailable = MutableStateFlow(voiceSearchAvailability.isVoiceSearchAvailable)
99119
private val voiceInputAllowed = MutableStateFlow(true)
120+
private var userSelectedMode: UserSelectedMode = NONE
100121
private val _visibilityState = MutableStateFlow(
101122
InputScreenVisibilityState(
102123
voiceInputButtonVisible = voiceServiceAvailable.value && voiceInputAllowed.value,
@@ -317,6 +338,13 @@ class InputScreenViewModel @AssistedInject constructor(
317338

318339
fun onSearchSubmitted(query: String) {
319340
command.value = Command.SubmitSearch(query)
341+
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_QUERY_SUBMITTED)
342+
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_QUERY_SUBMITTED_DAILY, type = Daily())
343+
344+
viewModelScope.launch {
345+
sessionStore.setHasUsedSearchMode(true)
346+
checkAndFireBothModesPixel()
347+
}
320348
}
321349

322350
fun onChatSubmitted(query: String) {
@@ -325,6 +353,13 @@ class InputScreenViewModel @AssistedInject constructor(
325353
} else {
326354
command.value = Command.SubmitChat(query)
327355
}
356+
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_PROMPT_SUBMITTED)
357+
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_PROMPT_SUBMITTED_DAILY, type = Daily())
358+
359+
viewModelScope.launch {
360+
sessionStore.setHasUsedChatMode(true)
361+
checkAndFireBothModesPixel()
362+
}
328363
}
329364

330365
fun onUserDismissedAutoCompleteInAppMessage() {
@@ -348,6 +383,17 @@ class InputScreenViewModel @AssistedInject constructor(
348383
it.copy(icon = SubmitButtonIcon.SEND)
349384
}
350385
}
386+
if (userSelectedMode == SEARCH) {
387+
fireModeSwitchedPixel()
388+
}
389+
userSelectedMode = CHAT
390+
}
391+
392+
fun onSearchSelected() {
393+
if (userSelectedMode == CHAT) {
394+
fireModeSwitchedPixel()
395+
}
396+
userSelectedMode = SEARCH
351397
}
352398

353399
fun onVoiceSearchDisabled() {
@@ -399,6 +445,21 @@ class InputScreenViewModel @AssistedInject constructor(
399445
return userHasModifiedInput || initialTextWasNotWebUrl
400446
}
401447

448+
fun fireShownPixel() {
449+
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SHOWN, type = Daily())
450+
}
451+
452+
private fun fireModeSwitchedPixel() {
453+
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_MODE_SWITCHED)
454+
}
455+
456+
private suspend fun checkAndFireBothModesPixel() {
457+
if (sessionStore.hasUsedSearchMode() && sessionStore.hasUsedChatMode()) {
458+
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES)
459+
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES_DAILY, type = Daily())
460+
}
461+
}
462+
402463
class InputScreenViewModelProviderFactory(
403464
private val assistedFactory: InputScreenViewModelFactory,
404465
private val currentOmnibarText: String,

0 commit comments

Comments
 (0)