From a6a3dc947d341108b2fa5b059c671fef9d7de27e Mon Sep 17 00:00:00 2001 From: Youssef Keyrouz Date: Thu, 20 Aug 2026 16:41:55 -0700 Subject: [PATCH 1/2] Add page_type and source to prompt submission pixels --- .../pixels/definitions/duck_chat.json5 | 46 +- .../pixels/params_dictionary.json | 35 + .../63.json | 1174 +++++++++++++++++ .../browser/nativeinput/NativeInputManager.kt | 9 +- .../java/com/duckduckgo/app/di/DaoModule.kt | 4 + .../duckduckgo/app/di/TabRepositoryModule.kt | 5 + .../duckduckgo/app/global/db/AppDatabase.kt | 20 +- .../app/tabs/db/DuckAiTabSessionDao.kt | 32 + .../app/tabs/db/DuckAiTabSessionEntity.kt | 38 + .../tabs/db/RealDuckAiTabSessionRepository.kt | 53 + .../app/tabs/model/TabDataRepository.kt | 3 + .../nativeinput/RealNativeInputManagerTest.kt | 1 + .../db/RealDuckAiTabSessionRepositoryTest.kt | 103 ++ .../tabs/model/TabDataRepositoryTest.kt | 3 + .../tabs/model/DuckAiTabSessionRepository.kt | 40 + .../duckduckgo/duckchat/impl/RealDuckChat.kt | 6 + .../pixel/DuckChatEntryPointPixelValue.kt | 22 + .../duckchat/impl/pixel/DuckChatPixels.kt | 118 +- .../impl/ui/NativeInputModeWidgetViewModel.kt | 3 + .../views/NativeInputModeWidget.kt | 8 + .../RealDuckChatPixelsAttachmentsTest.kt | 4 + .../pixel/RealDuckChatPixelsPickerTest.kt | 4 + .../impl/pixel/RealDuckChatPixelsToolsTest.kt | 225 ++++ .../duckchat/impl/RealDuckChatTest.kt | 3 + .../impl/pixel/RealDuckChatPixelsTest.kt | 42 +- .../ui/NativeInputModeWidgetViewModelTest.kt | 46 +- 26 files changed, 1973 insertions(+), 74 deletions(-) create mode 100644 app/schemas/com.duckduckgo.app.global.db.AppDatabase/63.json create mode 100644 app/src/main/java/com/duckduckgo/app/tabs/db/DuckAiTabSessionDao.kt create mode 100644 app/src/main/java/com/duckduckgo/app/tabs/db/DuckAiTabSessionEntity.kt create mode 100644 app/src/main/java/com/duckduckgo/app/tabs/db/RealDuckAiTabSessionRepository.kt create mode 100644 app/src/test/java/com/duckduckgo/tabs/db/RealDuckAiTabSessionRepositoryTest.kt create mode 100644 browser-api/src/main/java/com/duckduckgo/app/tabs/model/DuckAiTabSessionRepository.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatEntryPointPixelValue.kt diff --git a/PixelDefinitions/pixels/definitions/duck_chat.json5 b/PixelDefinitions/pixels/definitions/duck_chat.json5 index 7df7d4974799..561ca3528ef2 100644 --- a/PixelDefinitions/pixels/definitions/duck_chat.json5 +++ b/PixelDefinitions/pixels/definitions/duck_chat.json5 @@ -230,28 +230,28 @@ "owners": ["malmstein"], "triggers": ["other"], "suffixes": ["form_factor"], - "parameters": ["appVersion"] + "parameters": ["appVersion", "duckAiPromptPageType", "duckAiEntrySource"] }, "m_aichat_contextual_prompt_submitted_with_context_native_daily": { "description": "(Daily Pixel) The user has submitted a prompt to Contextual Duck.ai with the page context attached", "owners": ["malmstein"], "triggers": ["other"], "suffixes": ["form_factor"], - "parameters": ["appVersion"] + "parameters": ["appVersion", "duckAiPromptPageType", "duckAiEntrySource"] }, "m_aichat_contextual_prompt_submitted_without_context_native_count": { "description": "The user has submitted a prompt to Contextual Duck.ai without the page context attached", "owners": ["malmstein"], "triggers": ["other"], "suffixes": ["form_factor"], - "parameters": ["appVersion"] + "parameters": ["appVersion", "duckAiPromptPageType", "duckAiEntrySource"] }, "m_aichat_contextual_prompt_submitted_without_context_native_daily": { "description": "(Daily Pixel) The user has submitted a prompt to Contextual Duck.ai without the page context attached", "owners": ["malmstein"], "triggers": ["other"], "suffixes": ["form_factor"], - "parameters": ["appVersion"] + "parameters": ["appVersion", "duckAiPromptPageType", "duckAiEntrySource"] }, "m_aichat_contextual_page_context_collection_empty": { "description": "The page context collected via content scripts is not valid / is empty", @@ -446,35 +446,7 @@ "suffixes": ["first_daily_count", "form_factor"], "parameters": [ "appVersion", - { - "key": "source", - "type": "string", - "description": "The surface that initiated the Duck.ai entry.", - "enum": [ - "address_bar_prompt", - "address_bar_icon", - "address_bar_shortcut_chip", - "address_bar_editing_state", - "suggestion_ask_ai", - "browsing_menu_ntp", - "browsing_menu_webpage", - "tab_switcher", - "chat_history_new_chat", - "chat_history_open_chat", - "voice", - "onboarding", - "direct_url", - "serp", - "icon_shortcut", - "contextual_chat", - "widget_quick_actions", - "widget_favorite", - "system_search", - "digital_assistant", - "deep_link_other", - "paid_settings" - ] - }, + "duckAiEntrySource", { "key": "duck_ai_enabled", "type": "boolean", "description": "Whether Duck.ai is enabled by feature and user settings." }, { "key": "input_screen_enabled", @@ -1288,7 +1260,9 @@ }, { "key": "has_image_attachment", "type": "boolean", "description": "Whether the prompt included an image attachment" }, { "key": "has_file_attachment", "type": "boolean", "description": "Whether the prompt included a file attachment" }, - { "key": "has_text", "type": "boolean", "description": "Whether the prompt included text" } + { "key": "has_text", "type": "boolean", "description": "Whether the prompt included text" }, + "duckAiPromptPageType", + "duckAiEntrySource" ] }, "m_aichat_unified_input_prompt_submitted_daily": { @@ -1320,7 +1294,9 @@ }, { "key": "has_image_attachment", "type": "boolean", "description": "Whether the prompt included an image attachment" }, { "key": "has_file_attachment", "type": "boolean", "description": "Whether the prompt included a file attachment" }, - { "key": "has_text", "type": "boolean", "description": "Whether the prompt included text" } + { "key": "has_text", "type": "boolean", "description": "Whether the prompt included text" }, + "duckAiPromptPageType", + "duckAiEntrySource" ] }, "m_aichat_unified_input_sent_prompt_in_chat_count": { diff --git a/PixelDefinitions/pixels/params_dictionary.json b/PixelDefinitions/pixels/params_dictionary.json index 5a6b4b75d18b..795adb34d3a2 100644 --- a/PixelDefinitions/pixels/params_dictionary.json +++ b/PixelDefinitions/pixels/params_dictionary.json @@ -38,6 +38,41 @@ "none" ] }, + "duckAiPromptPageType": { + "key": "page_type", + "type": "string", + "description": "What the user was looking at when the prompt was submitted.", + "enum": ["ntp", "serp", "website", "duck_ai", "contextual", "unknown"] + }, + "duckAiEntrySource": { + "key": "source", + "type": "string", + "description": "The Duck.ai entry point: the surface that initiated the entry or prompt submission. Omitted when unknown.", + "enum": [ + "address_bar_prompt", + "address_bar_icon", + "address_bar_shortcut_chip", + "address_bar_editing_state", + "suggestion_ask_ai", + "browsing_menu_ntp", + "browsing_menu_webpage", + "tab_switcher", + "chat_history_new_chat", + "chat_history_open_chat", + "voice", + "onboarding", + "direct_url", + "serp", + "icon_shortcut", + "contextual_chat", + "widget_quick_actions", + "widget_favorite", + "system_search", + "digital_assistant", + "deep_link_other", + "paid_settings" + ] + }, "osVersion": { "key": "os_version", "type": "integer", diff --git a/app/schemas/com.duckduckgo.app.global.db.AppDatabase/63.json b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/63.json new file mode 100644 index 000000000000..0263abd69af9 --- /dev/null +++ b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/63.json @@ -0,0 +1,1174 @@ +{ + "formatVersion": 1, + "database": { + "version": 63, + "identityHash": "97a605e5f7404f385e50589ad93bb0d4", + "entities": [ + { + "tableName": "tds_tracker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `defaultAction` TEXT NOT NULL, `ownerName` TEXT NOT NULL, `categories` TEXT NOT NULL, `rules` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultAction", + "columnName": "defaultAction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerName", + "columnName": "ownerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rules", + "columnName": "rules", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "domain" + ] + } + }, + { + "tableName": "tds_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `displayName` TEXT NOT NULL, `prevalence` REAL NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevalence", + "columnName": "prevalence", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "tds_domain_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `entityName` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityName", + "columnName": "entityName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "domain" + ] + } + }, + { + "tableName": "tds_cname_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cloakedHostName` TEXT NOT NULL, `uncloakedHostName` TEXT NOT NULL, PRIMARY KEY(`cloakedHostName`))", + "fields": [ + { + "fieldPath": "cloakedHostName", + "columnName": "cloakedHostName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uncloakedHostName", + "columnName": "uncloakedHostName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cloakedHostName" + ] + } + }, + { + "tableName": "user_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "domain" + ] + } + }, + { + "tableName": "network_leaderboard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkName` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`networkName`))", + "fields": [ + { + "fieldPath": "networkName", + "columnName": "networkName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkName" + ] + } + }, + { + "tableName": "sites_visited", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "tabs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tabId` TEXT NOT NULL, `url` TEXT, `title` TEXT, `skipHome` INTEGER NOT NULL, `viewed` INTEGER NOT NULL, `position` INTEGER NOT NULL, `tabPreviewFile` TEXT, `sourceTabId` TEXT, `deletable` INTEGER NOT NULL, `lastAccessTime` TEXT, PRIMARY KEY(`tabId`), FOREIGN KEY(`sourceTabId`) REFERENCES `tabs`(`tabId`) ON UPDATE SET NULL ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "skipHome", + "columnName": "skipHome", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewed", + "columnName": "viewed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabPreviewFile", + "columnName": "tabPreviewFile", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceTabId", + "columnName": "sourceTabId", + "affinity": "TEXT" + }, + { + "fieldPath": "deletable", + "columnName": "deletable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessTime", + "columnName": "lastAccessTime", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tabId" + ] + }, + "indices": [ + { + "name": "index_tabs_tabId", + "unique": false, + "columnNames": [ + "tabId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tabs_tabId` ON `${TABLE_NAME}` (`tabId`)" + } + ], + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "SET NULL", + "onUpdate": "SET NULL", + "columns": [ + "sourceTabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + }, + { + "tableName": "tab_selection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `tabId` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`tabId`) REFERENCES `tabs`(`tabId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tab_selection_tabId", + "unique": false, + "columnNames": [ + "tabId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tab_selection_tabId` ON `${TABLE_NAME}` (`tabId`)" + } + ], + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "tabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + }, + { + "tableName": "tab_page_context", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tabId` TEXT NOT NULL, `url` TEXT NOT NULL, `serializedPageContext` TEXT NOT NULL, `collectedAt` INTEGER NOT NULL, PRIMARY KEY(`tabId`), FOREIGN KEY(`tabId`) REFERENCES `tabs`(`tabId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedPageContext", + "columnName": "serializedPageContext", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collectedAt", + "columnName": "collectedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tabId" + ] + }, + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + }, + { + "tableName": "webview_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tabId` TEXT NOT NULL, `sessionBundle` BLOB NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`tabId`), FOREIGN KEY(`tabId`) REFERENCES `tabs`(`tabId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionBundle", + "columnName": "sessionBundle", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tabId" + ] + }, + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `url` TEXT NOT NULL, `parentId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_favorites_title_url", + "unique": true, + "columnNames": [ + "title", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_favorites_title_url` ON `${TABLE_NAME}` (`title`, `url`)" + } + ] + }, + { + "tableName": "bookmark_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `parentId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "survey", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`surveyId` TEXT NOT NULL, `url` TEXT, `daysInstalled` INTEGER, `status` TEXT NOT NULL, PRIMARY KEY(`surveyId`))", + "fields": [ + { + "fieldPath": "surveyId", + "columnName": "surveyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "daysInstalled", + "columnName": "daysInstalled", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "surveyId" + ] + } + }, + { + "tableName": "dismissed_cta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ctaId` TEXT NOT NULL, PRIMARY KEY(`ctaId`))", + "fields": [ + { + "fieldPath": "ctaId", + "columnName": "ctaId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ctaId" + ] + } + }, + { + "tableName": "search_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "app_days_used", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `previous_date` TEXT, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "previousDate", + "columnName": "previous_date", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date" + ] + } + }, + { + "tableName": "app_enjoyment", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventType` INTEGER NOT NULL, `promptCount` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `primaryKey` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "promptCount", + "columnName": "promptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryKey", + "columnName": "primaryKey", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "primaryKey" + ] + } + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, PRIMARY KEY(`notificationId`))", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "notificationId" + ] + } + }, + { + "tableName": "privacy_protection_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `blocked_tracker_count` INTEGER NOT NULL, `upgrade_count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedTrackerCount", + "columnName": "blocked_tracker_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "upgradeCount", + "columnName": "upgrade_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "tdsMetadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `eTag` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "userStage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` INTEGER NOT NULL, `appStage` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appStage", + "columnName": "appStage", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "fireproofWebsites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "domain" + ] + } + }, + { + "tableName": "user_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "locationPermissions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `permission` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permission", + "columnName": "permission", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "domain" + ] + } + }, + { + "tableName": "pixel_store", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pixelName` TEXT NOT NULL, `atb` TEXT NOT NULL, `additionalQueryParams` TEXT NOT NULL, `encodedQueryParams` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pixelName", + "columnName": "pixelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atb", + "columnName": "atb", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "additionalQueryParams", + "columnName": "additionalQueryParams", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encodedQueryParams", + "columnName": "encodedQueryParams", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "page_loaded_pixel_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appVersion` TEXT NOT NULL, `elapsedTime` INTEGER NOT NULL, `webviewVersion` TEXT NOT NULL, `trackerOptimizationEnabled` INTEGER NOT NULL, `cpmEnabled` INTEGER NOT NULL, `isTabInForegroundOnFinish` INTEGER NOT NULL, `activeRequestsOnLoadStart` INTEGER NOT NULL, `concurrentRequestsOnFinish` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appVersion", + "columnName": "appVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "elapsedTime", + "columnName": "elapsedTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "webviewVersion", + "columnName": "webviewVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackerOptimizationEnabled", + "columnName": "trackerOptimizationEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cpmEnabled", + "columnName": "cpmEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTabInForegroundOnFinish", + "columnName": "isTabInForegroundOnFinish", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeRequestsOnLoadStart", + "columnName": "activeRequestsOnLoadStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concurrentRequestsOnFinish", + "columnName": "concurrentRequestsOnFinish", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "page_painted_pixel_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appVersion` TEXT NOT NULL, `elapsedTimeFirstPaint` INTEGER NOT NULL, `webViewVersion` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appVersion", + "columnName": "appVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "elapsedTimeFirstPaint", + "columnName": "elapsedTimeFirstPaint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "webViewVersion", + "columnName": "webViewVersion", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "web_trackers_blocked", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackerUrl` TEXT NOT NULL, `trackerCompany` TEXT NOT NULL, `timestamp` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackerUrl", + "columnName": "trackerUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackerCompany", + "columnName": "trackerCompany", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "auth_cookies_allowed_domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "domain" + ] + } + }, + { + "tableName": "entities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`entityId` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT, `type` TEXT NOT NULL, `lastModified` TEXT, `deleted` INTEGER NOT NULL, PRIMARY KEY(`entityId`))", + "fields": [ + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "entityId" + ] + } + }, + { + "tableName": "relations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `folderId` TEXT NOT NULL, `entityId` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "default_browser_prompts_app_usage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`isoDateET` TEXT NOT NULL, PRIMARY KEY(`isoDateET`))", + "fields": [ + { + "fieldPath": "isoDateET", + "columnName": "isoDateET", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "isoDateET" + ] + } + }, + { + "tableName": "duck_ai_tab_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tabId` TEXT NOT NULL, `entryPointSource` TEXT NOT NULL, PRIMARY KEY(`tabId`), FOREIGN KEY(`tabId`) REFERENCES `tabs`(`tabId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryPointSource", + "columnName": "entryPointSource", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tabId" + ] + }, + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '97a605e5f7404f385e50589ad93bb0d4')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt index f3ed34eff467..4d0471e9621c 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt @@ -221,7 +221,6 @@ class RealNativeInputManager @Inject constructor( private var navBarTabCountLiveData: LiveData? = null private var navBarTabCountObserver: Observer? = null private var lastCallbacks: NativeInputCallbacks? = null - private var nextDuckAiEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT /** True while an enter morph from [attachWidget] still owns [NativeInputLayoutCoordinator]'s animating flag. */ private var pendingEnterOwnsAnimating = false @@ -357,14 +356,14 @@ class RealNativeInputManager @Inject constructor( override fun handleDuckAiVoiceResult(query: String) { val widget = widgetFrom(rootView) if (widget != null) { - nextDuckAiEntryPoint = DuckChatEntryPoint.VOICE + widget.nextDuckAiEntryPoint = DuckChatEntryPoint.VOICE try { if (!widget.isChatTabSelected()) { widget.selectChatTab() } widget.submitMessage(query) } finally { - nextDuckAiEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT + widget.nextDuckAiEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT } } else { duckChat.openDuckChatWithAutoPrompt(query, DuckChatEntryPoint.VOICE) @@ -750,8 +749,8 @@ class RealNativeInputManager @Inject constructor( } isExiting = false nativeInputEventListener.onChatPromptSubmitted() - val entryPoint = nextDuckAiEntryPoint - nextDuckAiEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT + val entryPoint = widget.nextDuckAiEntryPoint + widget.nextDuckAiEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT callbacks.onDuckAiQuerySubmitted(query, entryPoint) } }, diff --git a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt index b7fe04f1ba3b..47b55ddccbfc 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt @@ -33,6 +33,7 @@ import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.privacy.db.UserAllowListDao import com.duckduckgo.app.survey.db.SurveyDao +import com.duckduckgo.app.tabs.db.DuckAiTabSessionDao import com.duckduckgo.app.tabs.db.TabPageContextDao import com.duckduckgo.app.tabs.db.TabsDao import com.duckduckgo.app.trackerdetection.db.TdsCnameEntityDao @@ -94,6 +95,9 @@ object DaoModule { @Provides fun providesTabPageContextDao(database: AppDatabase): TabPageContextDao = database.tabPageContextDao() + @Provides + fun providesDuckAiTabSessionDao(database: AppDatabase): DuckAiTabSessionDao = database.duckAiTabSessionDao() + @Provides fun surveyDao(database: AppDatabase): SurveyDao = database.surveyDao() diff --git a/app/src/main/java/com/duckduckgo/app/di/TabRepositoryModule.kt b/app/src/main/java/com/duckduckgo/app/di/TabRepositoryModule.kt index e17be6abf686..5d165fae5ef1 100644 --- a/app/src/main/java/com/duckduckgo/app/di/TabRepositoryModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/TabRepositoryModule.kt @@ -27,6 +27,7 @@ import com.duckduckgo.app.fire.store.TabVisitedSitesRepository import com.duckduckgo.app.global.model.SiteFactory import com.duckduckgo.app.tabs.TabManagerFeatureFlags import com.duckduckgo.app.tabs.db.TabsDao +import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository import com.duckduckgo.app.tabs.model.TabAtomicOperations import com.duckduckgo.app.tabs.model.TabDataRepository import com.duckduckgo.app.tabs.model.TabRepository @@ -80,6 +81,7 @@ abstract class TabRepositoryModule { duckChatContextualDataStore: DuckChatContextualDataStore, tabVisitedSitesRepository: TabVisitedSitesRepository, nativeInputStatePublisher: NativeInputStatePublisher, + duckAiTabSessionRepository: DuckAiTabSessionRepository, ): TabDataRepository = TabDataRepository( tabsDao = tabsDao, siteFactory = siteFactory, @@ -95,6 +97,7 @@ abstract class TabRepositoryModule { duckChatContextualDataStore = duckChatContextualDataStore, tabVisitedSitesRepository = tabVisitedSitesRepository, nativeInputStatePublisher = nativeInputStatePublisher, + duckAiTabSessionRepository = duckAiTabSessionRepository, ) @Provides @@ -115,6 +118,7 @@ abstract class TabRepositoryModule { duckChatContextualDataStore: DuckChatContextualDataStore, tabVisitedSitesRepository: TabVisitedSitesRepository, nativeInputStatePublisher: NativeInputStatePublisher, + duckAiTabSessionRepository: DuckAiTabSessionRepository, ): TabDataRepository = TabDataRepository( tabsDao = tabsDao, siteFactory = siteFactory, @@ -130,6 +134,7 @@ abstract class TabRepositoryModule { duckChatContextualDataStore = duckChatContextualDataStore, tabVisitedSitesRepository = tabVisitedSitesRepository, nativeInputStatePublisher = nativeInputStatePublisher, + duckAiTabSessionRepository = duckAiTabSessionRepository, ) } } diff --git a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt index 1eb5ff393547..1ba5c0e040cc 100644 --- a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt +++ b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt @@ -57,6 +57,8 @@ import com.duckduckgo.app.statistics.model.QueryParamsTypeConverter import com.duckduckgo.app.statistics.store.PendingPixelDao import com.duckduckgo.app.survey.db.SurveyDao import com.duckduckgo.app.survey.model.Survey +import com.duckduckgo.app.tabs.db.DuckAiTabSessionDao +import com.duckduckgo.app.tabs.db.DuckAiTabSessionEntity import com.duckduckgo.app.tabs.db.TabPageContextDao import com.duckduckgo.app.tabs.db.TabPageContextEntity import com.duckduckgo.app.tabs.db.TabsDao @@ -85,7 +87,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao */ @Database( exportSchema = true, - version = 62, + version = 63, entities = [ TdsTracker::class, TdsEntity::class, @@ -121,6 +123,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao Entity::class, Relation::class, DefaultBrowserPromptsAppUsageEntity::class, + DuckAiTabSessionEntity::class, ], ) @TypeConverters( @@ -148,6 +151,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun networkLeaderboardDao(): NetworkLeaderboardDao abstract fun tabsDao(): TabsDao abstract fun tabPageContextDao(): TabPageContextDao + abstract fun duckAiTabSessionDao(): DuckAiTabSessionDao abstract fun webViewSessionDao(): WebViewSessionDao abstract fun bookmarksDao(): BookmarksDao abstract fun favoritesDao(): FavoritesDao @@ -753,6 +757,19 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa } } + private val MIGRATION_62_TO_63: Migration = object : Migration(62, 63) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `duck_ai_tab_session` (" + + "`tabId` TEXT NOT NULL, " + + "`entryPointSource` TEXT NOT NULL, " + + "PRIMARY KEY(`tabId`), " + + "FOREIGN KEY(`tabId`) REFERENCES `tabs`(`tabId`) ON UPDATE NO ACTION ON DELETE CASCADE" + + ")", + ) + } + } + /** * WARNING ⚠️ * This needs to happen because Room doesn't support UNIQUE (...) ON CONFLICT REPLACE when creating the bookmarks table. @@ -840,6 +857,7 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa MIGRATION_59_TO_60, MIGRATION_60_TO_61, MIGRATION_61_TO_62, + MIGRATION_62_TO_63, ) @Deprecated( diff --git a/app/src/main/java/com/duckduckgo/app/tabs/db/DuckAiTabSessionDao.kt b/app/src/main/java/com/duckduckgo/app/tabs/db/DuckAiTabSessionDao.kt new file mode 100644 index 000000000000..8fd8febccdfb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/tabs/db/DuckAiTabSessionDao.kt @@ -0,0 +1,32 @@ +/* + * 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.app.tabs.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface DuckAiTabSessionDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(entity: DuckAiTabSessionEntity) + + @Query("SELECT entryPointSource FROM duck_ai_tab_session WHERE tabId = :tabId") + suspend fun getEntryPointSource(tabId: String): String? +} diff --git a/app/src/main/java/com/duckduckgo/app/tabs/db/DuckAiTabSessionEntity.kt b/app/src/main/java/com/duckduckgo/app/tabs/db/DuckAiTabSessionEntity.kt new file mode 100644 index 000000000000..5462df664d66 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/tabs/db/DuckAiTabSessionEntity.kt @@ -0,0 +1,38 @@ +/* + * 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.app.tabs.db + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.duckduckgo.app.tabs.model.TabEntity + +@Entity( + tableName = "duck_ai_tab_session", + foreignKeys = [ + ForeignKey( + entity = TabEntity::class, + parentColumns = ["tabId"], + childColumns = ["tabId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class DuckAiTabSessionEntity( + @PrimaryKey val tabId: String, + val entryPointSource: String, +) diff --git a/app/src/main/java/com/duckduckgo/app/tabs/db/RealDuckAiTabSessionRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/db/RealDuckAiTabSessionRepository.kt new file mode 100644 index 000000000000..faf5fbb507c2 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/tabs/db/RealDuckAiTabSessionRepository.kt @@ -0,0 +1,53 @@ +/* + * 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.app.tabs.db + +import androidx.core.net.toUri +import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckChat +import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy +import dagger.SingleInstanceIn +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealDuckAiTabSessionRepository @Inject constructor( + private val dao: DuckAiTabSessionDao, + // Lazy because RealDuckChat itself depends on this repository to set the pending entry point. + private val duckChat: Lazy, +) : DuckAiTabSessionRepository { + + private val pendingEntryPointSource = AtomicReference() + + override fun setPendingEntryPointSource(source: String) { + pendingEntryPointSource.set(source) + } + + override fun tryClaimEntryPointSource(tabId: String, url: String?) { + val source = pendingEntryPointSource.get() ?: return + if (url == null || !duckChat.get().isDuckChatUrl(url.toUri())) return + // Only the call that actually matches consumes the pending value, so an unrelated tab + // creation/navigation racing in between never steals or clears it from the real target. + if (!pendingEntryPointSource.compareAndSet(source, null)) return + runCatching { dao.insertOrReplace(DuckAiTabSessionEntity(tabId = tabId, entryPointSource = source)) } + } + + override suspend fun getEntryPointSource(tabId: String): String? = dao.getEntryPointSource(tabId) +} diff --git a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt index 66f015ab6df0..e4a6a17432ef 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt @@ -70,6 +70,7 @@ class TabDataRepository( private val duckChatContextualDataStore: DuckChatContextualDataStore, private val tabVisitedSitesRepository: TabVisitedSitesRepository, private val nativeInputStatePublisher: NativeInputStatePublisher, + private val duckAiTabSessionRepository: DuckAiTabSessionRepository, ) : TabRepository, TabAtomicOperations { override val liveTabs: LiveData> = tabsDao.liveTabs().distinctUntilChanged() @@ -227,6 +228,7 @@ class TabDataRepository( ), updateIfBlankParent = updateIfBlankParent, ) + duckAiTabSessionRepository.tryClaimEntryPointSource(tabId, data.value?.url) } } @@ -299,6 +301,7 @@ class TabDataRepository( ) { databaseExecutor().scheduleDirect { tabsDao.updateUrlAndTitle(tabId, site?.url, site?.title, viewed = true) + duckAiTabSessionRepository.tryClaimEntryPointSource(tabId, site?.url) } } diff --git a/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt index a613475aba78..5995c234bad1 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/nativeinput/RealNativeInputManagerTest.kt @@ -464,6 +464,7 @@ class RealNativeInputManagerTest { private var onChatSubmitted: ((String) -> Unit)? = null override var text: String = "" + override var nextDuckAiEntryPoint: DuckChatEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT override fun bindInputEvents( onSearchTextChanged: (String) -> Unit, diff --git a/app/src/test/java/com/duckduckgo/tabs/db/RealDuckAiTabSessionRepositoryTest.kt b/app/src/test/java/com/duckduckgo/tabs/db/RealDuckAiTabSessionRepositoryTest.kt new file mode 100644 index 000000000000..78e3d9d18920 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/tabs/db/RealDuckAiTabSessionRepositoryTest.kt @@ -0,0 +1,103 @@ +/* + * 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.tabs.db + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.tabs.db.DuckAiTabSessionDao +import com.duckduckgo.app.tabs.db.DuckAiTabSessionEntity +import com.duckduckgo.app.tabs.db.RealDuckAiTabSessionRepository +import com.duckduckgo.duckchat.api.DuckChat +import dagger.Lazy +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class RealDuckAiTabSessionRepositoryTest { + + private val mockDao: DuckAiTabSessionDao = mock() + private val mockDuckChat: DuckChat = mock() + private lateinit var testee: RealDuckAiTabSessionRepository + + @Before + fun setup() { + testee = RealDuckAiTabSessionRepository(dao = mockDao, duckChat = Lazy { mockDuckChat }) + } + + @Test + fun whenTabCreatedWithDuckAiUrlAndPendingSourceThenAttributed() = runTest { + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true) + testee.setPendingEntryPointSource("browsing_menu_webpage") + + testee.tryClaimEntryPointSource("tab1", "https://duck.ai/chat") + + verify(mockDao).insertOrReplace(DuckAiTabSessionEntity(tabId = "tab1", entryPointSource = "browsing_menu_webpage")) + } + + @Test + fun whenTabCreatedWithNoPendingSourceThenNothingStored() = runTest { + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true) + + testee.tryClaimEntryPointSource("tab1", "https://duck.ai/chat") + + verify(mockDao, never()).insertOrReplace(any()) + } + + @Test + fun whenUnrelatedTabRacesInWithNonDuckAiUrlThenPendingSourceSurvivesForTheRealTarget() = runTest { + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(false) + testee.setPendingEntryPointSource("browsing_menu_webpage") + + // An unrelated tab creation/navigation happening to run first must not consume the pending + // value — otherwise the real Duck.ai tab created moments later would lose its attribution. + testee.tryClaimEntryPointSource("unrelated-tab", "https://example.com") + verify(mockDao, never()).insertOrReplace(any()) + + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true) + testee.tryClaimEntryPointSource("tab1", "https://duck.ai/chat") + + verify(mockDao).insertOrReplace(DuckAiTabSessionEntity(tabId = "tab1", entryPointSource = "browsing_menu_webpage")) + } + + @Test + fun whenPendingSourceAlreadyConsumedThenSecondTabDoesNotClaimIt() = runTest { + whenever(mockDuckChat.isDuckChatUrl(any())).thenReturn(true) + testee.setPendingEntryPointSource("voice") + + testee.tryClaimEntryPointSource("tab1", "https://duck.ai/chat") + testee.tryClaimEntryPointSource("tab2", "https://duck.ai/chat") + + verify(mockDao).insertOrReplace(DuckAiTabSessionEntity(tabId = "tab1", entryPointSource = "voice")) + verify(mockDao, never()).insertOrReplace(DuckAiTabSessionEntity(tabId = "tab2", entryPointSource = "voice")) + } + + @Test + fun whenGetEntryPointSourceThenReadsFromDao() = runTest { + whenever(mockDao.getEntryPointSource("tab1")).thenReturn("chat_history_new_chat") + + val result = testee.getEntryPointSource("tab1") + + assertEquals("chat_history_new_chat", result) + } +} diff --git a/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt b/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt index fe88c107b9de..e5c50de8055f 100644 --- a/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt +++ b/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt @@ -35,6 +35,7 @@ import com.duckduckgo.app.global.model.SiteFactoryImpl import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.tabs.TabManagerFeatureFlags import com.duckduckgo.app.tabs.db.TabsDao +import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository import com.duckduckgo.app.tabs.model.TabDataRepository import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabSelectionEntity @@ -779,6 +780,7 @@ class TabDataRepositoryTest { contextualDataStore: DuckChatContextualDataStore = mockDuckChatContextualDataStore, tabVisitedSitesRepository: TabVisitedSitesRepository = mockTabVisitedSitesRepository, nativeInputStatePublisher: NativeInputStatePublisher = mockNativeInputStatePublisher, + duckAiTabSessionRepository: DuckAiTabSessionRepository = mock(), ): TabDataRepository { return TabDataRepository( dao, @@ -805,6 +807,7 @@ class TabDataRepositoryTest { contextualDataStore, tabVisitedSitesRepository, nativeInputStatePublisher, + duckAiTabSessionRepository, ) } diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/DuckAiTabSessionRepository.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/DuckAiTabSessionRepository.kt new file mode 100644 index 000000000000..c1b0d5819ac1 --- /dev/null +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/DuckAiTabSessionRepository.kt @@ -0,0 +1,40 @@ +/* + * 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.app.tabs.model + +/** + * Stores session related attributes for Duck.ai tabs. Storage is keyed by tab and cleaned up automatically when the tab is closed. + * Currently only holds the source entry point for the Duck.ai tab. + */ +interface DuckAiTabSessionRepository { + + /** + * Records [source] as the entry point of the next tab created or navigated to a Duck.ai URL. + * Call this before triggering the navigation. Consumed at most once . + */ + fun setPendingEntryPointSource(source: String) + + /** + * Claims the pending entry point set via [setPendingEntryPointSource] for [tabId], if [url] is a + * Duck.ai URL. No-ops if nothing is pending. Safe to call for every tab creation/navigation, + * the pending value is consumed at most once, so unrelated tabs never claim it. + */ + fun tryClaimEntryPointSource(tabId: String, url: String?) + + /** The entry point recorded for [tabId], or null if none was ever attributed. */ + suspend fun getEntryPointSource(tabId: String): String? +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index 87fcae1f29a7..e32a96aca86d 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.repeatOnLifecycle import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.di.IsMainProcess import com.duckduckgo.app.tabs.BrowserNav +import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.browsermode.api.BrowserMode import com.duckduckgo.common.utils.AppUrl @@ -45,6 +46,7 @@ import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.impl.feature.AIChatImageUploadFeature import com.duckduckgo.duckchat.impl.feature.DuckChatFeature import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels +import com.duckduckgo.duckchat.impl.pixel.toPixelValue import com.duckduckgo.duckchat.impl.repository.AddressBarPickerAttributionRepository import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository import com.duckduckgo.duckchat.impl.store.DefaultTogglePosition @@ -470,6 +472,7 @@ class RealDuckChat @Inject constructor( private val appBuildConfig: AppBuildConfig, private val voiceSessionStateManager: VoiceSessionStateManager, private val chatSuggestionsStore: ChatSuggestionsStore, + private val duckAiTabSessionRepository: DuckAiTabSessionRepository, ) : DuckChatInternal, DuckAiFeatureState, DuckChatInputModeState, @@ -799,6 +802,9 @@ class RealDuckChat @Inject constructor( opensNewTab: Boolean, hasPrompt: Boolean, ) { + // Lets the tab that ends up hosting this entry attribute itself once it's created/navigated, + // so a later prompt submission in it can carry this same entry point as its `source`. + duckAiTabSessionRepository.setPendingEntryPointSource(entryPoint.toPixelValue()) duckChatPixels.get().sendDuckChatEntryPixel( entryPoint = entryPoint, opensNewTab = opensNewTab, diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatEntryPointPixelValue.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatEntryPointPixelValue.kt new file mode 100644 index 000000000000..834d14ca370f --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatEntryPointPixelValue.kt @@ -0,0 +1,22 @@ +/* + * 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.duckchat.api.DuckChatEntryPoint + +/** The lower-snake wire value for [DuckChatEntryPoint], sent as the `source` pixel parameter. */ +internal fun DuckChatEntryPoint.toPixelValue(): String = name.lowercase() diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt index dfbe94aff14a..d2d01bff50e4 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt @@ -16,9 +16,15 @@ package com.duckduckgo.duckchat.impl.pixel +import androidx.core.net.toUri +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.browsermode.api.BrowserModeDataProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter @@ -26,6 +32,7 @@ import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.ToggleSelection +import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.ModelTier import com.duckduckgo.duckchat.impl.ReportMetric import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_CREATE_NEW_CHAT @@ -208,6 +215,8 @@ interface DuckChatPixels { hasText: Boolean, surface: DuckChatPixelSurface, defaultMode: ToggleSelection?, + tabId: String?, + addressBarEntryPoint: DuckChatEntryPoint?, ) /** Prompt submitted while the unified input is in a Duck.ai chat context. Fires alongside [firePromptSubmitted]. */ @@ -273,6 +282,10 @@ class RealDuckChatPixels @Inject constructor( private val statisticsUpdater: StatisticsUpdater, private val duckAiMetricCollector: DuckAiMetricCollector, private val termsOfServiceHandler: DuckChatTermsOfServiceHandler, + private val tabRepositoryProvider: BrowserModeDataProvider, + private val duckAiTabSessionRepository: DuckAiTabSessionRepository, + private val duckChatInternal: DuckChatInternal, + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, ) : DuckChatPixels { private fun fireCountAndDaily( @@ -309,6 +322,45 @@ class RealDuckChatPixels @Inject constructor( ) } + /** What the user was looking at when a prompt was submitted. See [DuckChatPixelParameters.PROMPT_PAGE_TYPE]. */ + private suspend fun resolvePromptPageType( + surface: DuckChatPixelSurface, + tabId: String?, + ): String = when (surface) { + DuckChatPixelSurface.DUCK_AI -> "duck_ai" + DuckChatPixelSurface.CONTEXTUAL_CHAT -> "contextual" + DuckChatPixelSurface.ADDRESS_BAR -> { + val tab = tabId?.let { findTab(it) } + val url = tab?.url + when { + tab == null -> "unknown" + url.isNullOrBlank() -> "ntp" + duckChatInternal.isDuckChatUrl(url.toUri()) -> "duck_ai" + duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url) -> "serp" + else -> "website" + } + } + } + + /** [tabId] may belong to either browsing modes Regular or Fire mode (They are backed by separate databases). */ + private suspend fun findTab(tabId: String) = + tabRepositoryProvider.forMode(BrowserMode.REGULAR).getTab(tabId) + ?: tabRepositoryProvider.forMode(BrowserMode.FIRE).getTab(tabId) + + /** + * The `source` for a prompt submission. Inside an existing Duck.ai chat, the entry point is carried forward + * from whatever was recorded for [tabId] when that chat was entered. + */ + private suspend fun resolveEntrySource( + surface: DuckChatPixelSurface, + tabId: String?, + addressBarEntryPoint: DuckChatEntryPoint?, + ): String? = when (surface) { + DuckChatPixelSurface.DUCK_AI -> tabId?.let { duckAiTabSessionRepository.getEntryPointSource(it) } + DuckChatPixelSurface.CONTEXTUAL_CHAT -> DuckChatEntryPoint.CONTEXTUAL_CHAT.toPixelValue() + DuckChatPixelSurface.ADDRESS_BAR -> addressBarEntryPoint?.toPixelValue() + } + override fun reportContextualSuggestionSelected( suggestionId: String, pageType: String, @@ -538,11 +590,22 @@ class RealDuckChatPixels @Inject constructor( override fun reportContextualPromptSubmittedWithContextNative() { appCoroutineScope.launch(dispatcherProvider.io()) { - pixel.fire(DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITH_CONTEXT_NATIVE_COUNT) - pixel.fire(DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITH_CONTEXT_NATIVE_DAILY, type = Pixel.PixelType.Daily()) + val params = contextualPromptSubmittedParams() + pixel.fire(DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITH_CONTEXT_NATIVE_COUNT, parameters = params) + pixel.fire( + DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITH_CONTEXT_NATIVE_DAILY, + parameters = params, + type = Pixel.PixelType.Daily(), + ) } } + /** The contextual sheet is always entered by using it, so both params are constants — no lookup needed. */ + private fun contextualPromptSubmittedParams(): Map = mapOf( + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "contextual", + DuckChatPixelParameters.ENTRY_SOURCE to DuckChatEntryPoint.CONTEXTUAL_CHAT.toPixelValue(), + ) + override fun reportContextualPageContextAutoAttached() { appCoroutineScope.launch(dispatcherProvider.io()) { pixel.fire(DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_PAGE_CONTEXT_AUTO_ATTACHED_COUNT) @@ -552,8 +615,13 @@ class RealDuckChatPixels @Inject constructor( override fun reportContextualPromptSubmittedWithoutContextNative() { appCoroutineScope.launch(dispatcherProvider.io()) { - pixel.fire(DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITHOUT_CONTEXT_NATIVE_COUNT) - pixel.fire(DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITHOUT_CONTEXT_NATIVE_DAILY, type = Pixel.PixelType.Daily()) + val params = contextualPromptSubmittedParams() + pixel.fire(DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITHOUT_CONTEXT_NATIVE_COUNT, parameters = params) + pixel.fire( + DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITHOUT_CONTEXT_NATIVE_DAILY, + parameters = params, + type = Pixel.PixelType.Daily(), + ) } } @@ -751,24 +819,29 @@ class RealDuckChatPixels @Inject constructor( hasText: Boolean, surface: DuckChatPixelSurface, defaultMode: ToggleSelection?, + tabId: String?, + addressBarEntryPoint: DuckChatEntryPoint?, ) { - val params = buildMap { - put(DuckChatPixelParameters.SELECTED_TOOL, selectedTool) - modelId?.let { put(DuckChatPixelParameters.MODEL_ID, it) } - reasoningEffort?.let { put(DuckChatPixelParameters.REASONING_EFFORT, it) } - put(DuckChatPixelParameters.HAS_IMAGE_ATTACHMENT, hasImageAttachment.toString()) - put(DuckChatPixelParameters.HAS_FILE_ATTACHMENT, hasFileAttachment.toString()) - put(DuckChatPixelParameters.HAS_TEXT, hasText.toString()) - put(DuckChatPixelParameters.SURFACE, surface.value) - defaultMode - ?.takeIf { surface == DuckChatPixelSurface.ADDRESS_BAR } - ?.let { put(DuckChatPixelParameters.DEFAULT_MODE, it.pixelValue()) } + appCoroutineScope.launch(dispatcherProvider.io()) { + val pageType = resolvePromptPageType(surface, tabId) + val source = resolveEntrySource(surface, tabId, addressBarEntryPoint) + val params = buildMap { + put(DuckChatPixelParameters.SELECTED_TOOL, selectedTool) + modelId?.let { put(DuckChatPixelParameters.MODEL_ID, it) } + reasoningEffort?.let { put(DuckChatPixelParameters.REASONING_EFFORT, it) } + put(DuckChatPixelParameters.HAS_IMAGE_ATTACHMENT, hasImageAttachment.toString()) + put(DuckChatPixelParameters.HAS_FILE_ATTACHMENT, hasFileAttachment.toString()) + put(DuckChatPixelParameters.HAS_TEXT, hasText.toString()) + put(DuckChatPixelParameters.SURFACE, surface.value) + defaultMode + ?.takeIf { surface == DuckChatPixelSurface.ADDRESS_BAR } + ?.let { put(DuckChatPixelParameters.DEFAULT_MODE, it.pixelValue()) } + put(DuckChatPixelParameters.PROMPT_PAGE_TYPE, pageType) + source?.let { put(DuckChatPixelParameters.ENTRY_SOURCE, it) } + } + pixel.fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) + pixel.fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_DAILY, parameters = params, type = Pixel.PixelType.Daily()) } - fireCountAndDaily( - DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, - DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_DAILY, - params, - ) } override fun fireSentPromptInChat(surface: DuckChatPixelSurface) = fireCountAndDaily( @@ -1375,6 +1448,9 @@ object DuckChatPixelParameters { const val WAS_USED_BEFORE = "was_used_before" const val SUGGESTION_ID = "suggestionId" const val PAGE_TYPE = "pageType" + + /** What the user was looking at when a prompt was submitted. Distinct from [PAGE_TYPE], which classifies contextual suggestions. */ + const val PROMPT_PAGE_TYPE = "page_type" const val IS_SMART = "isSmart" const val DELTA_TIMESTAMP_PARAMETERS = "delta-timestamp-minutes" const val INPUT_SCREEN_MODE = "mode" @@ -1664,5 +1740,3 @@ internal fun ToggleSelection.pixelValue(): String = when (this) { ToggleSelection.SEARCH -> "search" ToggleSelection.DUCK_AI -> "duck_ai" } - -private fun DuckChatEntryPoint.toPixelValue(): String = name.lowercase() diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt index b8ecf71a7bb3..834aae34be85 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt @@ -262,6 +262,7 @@ class NativeInputModeWidgetViewModel @Inject constructor( hasText: Boolean, hasImageAttachment: Boolean, hasFileAttachment: Boolean, + addressBarEntryPoint: DuckChatEntryPoint, ) { val tool = getSelectedTool()?.let { Tool.from(it) } val selectedToolParam = when (tool) { @@ -280,6 +281,8 @@ class NativeInputModeWidgetViewModel @Inject constructor( hasText = hasText, surface = surface, defaultMode = resolvedTogglePositionIfVisible(inputState), + tabId = activeTabId.value, + addressBarEntryPoint = addressBarEntryPoint, ) when (tool) { Tool.IMAGE_GENERATION -> duckChatPixels.fireImageGenerationSubmitted(surface) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index 354cb557042b..70239c9e8d38 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -71,6 +71,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.ViewViewModelFactory import com.duckduckgo.common.utils.extensions.showKeyboard import com.duckduckgo.di.scopes.ViewScope +import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.InputMode import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.InteractionLock @@ -132,6 +133,11 @@ interface NativeInputWidget { var onCustomizeResponsesClicked: (() -> Unit)? val isModelMenuVisible: Boolean + /** + * The entry point that will be reported when Duck.ai is opened or a prompt is submitted + */ + var nextDuckAiEntryPoint: DuckChatEntryPoint + fun onBackPressed() fun focusInput(activity: Activity?) fun hasInputFocus(): Boolean @@ -430,6 +436,7 @@ class NativeInputModeWidget @JvmOverloads constructor( override var onBack: (() -> Unit)? = null var onSearchSent: ((String) -> Unit)? = null var onChatSent: ((String) -> Unit)? = null + override var nextDuckAiEntryPoint: DuckChatEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT override var onSearchSelected: ((animate: Boolean) -> Unit)? = null override var onChatSelected: ((animate: Boolean) -> Unit)? = null var onSearchTextChanged: ((String) -> Unit)? = null @@ -1380,6 +1387,7 @@ class NativeInputModeWidget @JvmOverloads constructor( hasText = hasText, hasImageAttachment = hasImageAttachment, hasFileAttachment = hasFileAttachment, + addressBarEntryPoint = nextDuckAiEntryPoint, ) // Fires alongside prompt_submitted, but only when the input is in a Duck.ai chat context // (a prompt sent from within an active chat) — not omnibar submissions that start a new chat. diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsAttachmentsTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsAttachmentsTest.kt index 40c6fec868f3..07d6a7c6e021 100644 --- a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsAttachmentsTest.kt +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsAttachmentsTest.kt @@ -47,6 +47,10 @@ class RealDuckChatPixelsAttachmentsTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = termsOfServiceHandler, + tabRepositoryProvider = mock(), + duckAiTabSessionRepository = mock(), + duckDuckGoUrlDetector = mock(), + duckChatInternal = mock(), ) @Test diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsPickerTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsPickerTest.kt index 46cc16285216..3e4203ab76bb 100644 --- a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsPickerTest.kt +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsPickerTest.kt @@ -47,6 +47,10 @@ class RealDuckChatPixelsPickerTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = termsOfServiceHandler, + tabRepositoryProvider = mock(), + duckAiTabSessionRepository = mock(), + duckDuckGoUrlDetector = mock(), + duckChatInternal = mock(), ) @Test diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsToolsTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsToolsTest.kt index 7e1778055be0..b0e69e9de3b4 100644 --- a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsToolsTest.kt +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsToolsTest.kt @@ -16,19 +16,32 @@ package com.duckduckgo.duckchat.impl.pixel +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.browsermode.api.BrowserModeDataProvider import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.ToggleSelection +import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.helper.DuckChatTermsOfServiceHandler import com.duckduckgo.duckchat.impl.metric.DuckAiMetricCollector import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +@RunWith(AndroidJUnit4::class) class RealDuckChatPixelsToolsTest { @get:Rule @@ -39,6 +52,17 @@ class RealDuckChatPixelsToolsTest { private val statisticsUpdater: StatisticsUpdater = mock() private val duckAiMetricCollector: DuckAiMetricCollector = mock() private val termsOfServiceHandler: DuckChatTermsOfServiceHandler = mock() + private val duckChatInternal: DuckChatInternal = mock() + private val regularTabRepository: TabRepository = mock() + private val fireTabRepository: TabRepository = mock() + private val tabRepositoryProvider: BrowserModeDataProvider = mock() + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() + + @Before + fun setup() { + whenever(tabRepositoryProvider.forMode(BrowserMode.REGULAR)).thenReturn(regularTabRepository) + whenever(tabRepositoryProvider.forMode(BrowserMode.FIRE)).thenReturn(fireTabRepository) + } private val testee = RealDuckChatPixels( pixel = pixel, @@ -48,6 +72,10 @@ class RealDuckChatPixelsToolsTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = termsOfServiceHandler, + tabRepositoryProvider = tabRepositoryProvider, + duckAiTabSessionRepository = mock(), + duckDuckGoUrlDetector = duckDuckGoUrlDetector, + duckChatInternal = duckChatInternal, ) private val surfaceParams = mapOf(DuckChatPixelParameters.SURFACE to "contextual_chat") @@ -147,6 +175,8 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.CONTEXTUAL_CHAT, defaultMode = null, + tabId = null, + addressBarEntryPoint = null, ) val params = mapOf( @@ -157,6 +187,8 @@ class RealDuckChatPixelsToolsTest { DuckChatPixelParameters.HAS_FILE_ATTACHMENT to "false", DuckChatPixelParameters.HAS_TEXT to "true", DuckChatPixelParameters.SURFACE to "contextual_chat", + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "contextual", + DuckChatPixelParameters.ENTRY_SOURCE to "contextual_chat", ) verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) verify(pixel).fire( @@ -177,6 +209,8 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, + tabId = null, + addressBarEntryPoint = null, ) val params = mapOf( @@ -185,6 +219,7 @@ class RealDuckChatPixelsToolsTest { DuckChatPixelParameters.HAS_FILE_ATTACHMENT to "false", DuckChatPixelParameters.HAS_TEXT to "true", DuckChatPixelParameters.SURFACE to "address_bar", + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "unknown", ) verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) verify(pixel).fire( @@ -194,6 +229,188 @@ class RealDuckChatPixelsToolsTest { ) } + @Test + fun whenPromptSubmittedFromAddressBarOnBlankTabThenPageTypeIsNtp() = runTest { + whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = null)) + + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "tab1", + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) + + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + parameters = promptSubmittedAddressBarParams(pageType = "ntp"), + ) + } + + @Test + fun whenPromptSubmittedFromAddressBarOnWebsiteThenPageTypeIsWebsite() = runTest { + whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = "https://example.com")) + whenever(duckChatInternal.isDuckChatUrl(any())).thenReturn(false) + whenever(duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(false) + + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "tab1", + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) + + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + parameters = promptSubmittedAddressBarParams(pageType = "website"), + ) + } + + @Test + fun whenPromptSubmittedFromAddressBarOnSerpThenPageTypeIsSerp() = runTest { + whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = "https://duckduckgo.com/?q=test")) + whenever(duckChatInternal.isDuckChatUrl(any())).thenReturn(false) + whenever(duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true) + + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "tab1", + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) + + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + parameters = promptSubmittedAddressBarParams(pageType = "serp"), + ) + } + + @Test + fun whenPromptSubmittedFromAddressBarAndTabExistsOnlyInFireModeThenPageTypeIsResolvedFromFireRepository() = runTest { + // The tab isn't in the Regular-mode tabs table at all — it lives in Fire mode's own database. + whenever(regularTabRepository.getTab("fire-tab")).thenReturn(null) + whenever(fireTabRepository.getTab("fire-tab")).thenReturn(TabEntity(tabId = "fire-tab", url = "https://example.com")) + whenever(duckChatInternal.isDuckChatUrl(any())).thenReturn(false) + whenever(duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(false) + + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "fire-tab", + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) + + // Must resolve to the tab's real page type, not fall back to "ntp" just because it was + // missing from the Regular-mode repository. + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + parameters = promptSubmittedAddressBarParams(pageType = "website"), + ) + } + + @Test + fun whenPromptSubmittedFromAddressBarAndTabIsNotFoundInEitherModeThenPageTypeIsUnknown() = runTest { + whenever(regularTabRepository.getTab("missing-tab")).thenReturn(null) + whenever(fireTabRepository.getTab("missing-tab")).thenReturn(null) + + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "missing-tab", + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) + + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + parameters = promptSubmittedAddressBarParams(pageType = "unknown"), + ) + } + + @Test + fun whenPromptSubmittedFromAddressBarWithVoiceEntryPointThenSourceIsVoice() = runTest { + whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = null)) + + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "tab1", + addressBarEntryPoint = DuckChatEntryPoint.VOICE, + ) + + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + parameters = promptSubmittedAddressBarParams(pageType = "ntp", source = "voice"), + ) + } + + @Test + fun whenPromptSubmittedFromAddressBarWithNoEntryPointThenSourceIsOmitted() = runTest { + whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = null)) + + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "tab1", + addressBarEntryPoint = null, + ) + + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + parameters = promptSubmittedAddressBarParams(pageType = "ntp", source = null), + ) + } + + private fun promptSubmittedAddressBarParams(pageType: String, source: String? = "address_bar_prompt") = buildMap { + put(DuckChatPixelParameters.SELECTED_TOOL, "none") + put(DuckChatPixelParameters.HAS_IMAGE_ATTACHMENT, "false") + put(DuckChatPixelParameters.HAS_FILE_ATTACHMENT, "false") + put(DuckChatPixelParameters.HAS_TEXT, "true") + put(DuckChatPixelParameters.SURFACE, "address_bar") + put(DuckChatPixelParameters.PROMPT_PAGE_TYPE, pageType) + source?.let { put(DuckChatPixelParameters.ENTRY_SOURCE, it) } + } + @Test fun whenPromptSubmittedFromAddressBarThenDefaultModeIsIncluded() = runTest { testee.firePromptSubmitted( @@ -205,6 +422,8 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = ToggleSelection.DUCK_AI, + tabId = null, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) val params = mapOf( @@ -214,6 +433,8 @@ class RealDuckChatPixelsToolsTest { DuckChatPixelParameters.HAS_TEXT to "true", DuckChatPixelParameters.SURFACE to "address_bar", DuckChatPixelParameters.DEFAULT_MODE to "duck_ai", + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "unknown", + DuckChatPixelParameters.ENTRY_SOURCE to "address_bar_prompt", ) verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) verify(pixel).fire( @@ -236,6 +457,8 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.CONTEXTUAL_CHAT, defaultMode = ToggleSelection.DUCK_AI, + tabId = null, + addressBarEntryPoint = null, ) val params = mapOf( @@ -244,6 +467,8 @@ class RealDuckChatPixelsToolsTest { DuckChatPixelParameters.HAS_FILE_ATTACHMENT to "false", DuckChatPixelParameters.HAS_TEXT to "true", DuckChatPixelParameters.SURFACE to "contextual_chat", + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "contextual", + DuckChatPixelParameters.ENTRY_SOURCE to "contextual_chat", ) verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) verify(pixel).fire( diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt index b26c48b8199e..03ea0fe6bb14 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.testing.TestLifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.app.tabs.BrowserNav +import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.browsermode.api.BrowserMode import com.duckduckgo.common.test.CoroutineTestRule @@ -107,6 +108,7 @@ class RealDuckChatTest { private val mockAppBuildConfig: AppBuildConfig = mock() private val mockVoiceSessionStateManager: VoiceSessionStateManager = mock() private val chatSuggestionsStore: ChatSuggestionsStore = mock() + private val mockDuckAiTabSessionRepository: DuckAiTabSessionRepository = mock() private lateinit var testee: RealDuckChat @@ -150,6 +152,7 @@ class RealDuckChatTest { mockAppBuildConfig, mockVoiceSessionStateManager, chatSuggestionsStore, + mockDuckAiTabSessionRepository, ), ) coroutineRule.testScope.advanceUntilIdle() diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsTest.kt index 7bd46d5da196..c8f5c59a5773 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsTest.kt @@ -16,11 +16,17 @@ package com.duckduckgo.duckchat.impl.pixel +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.browsermode.api.BrowserModeDataProvider import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.ToggleSelection +import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.ReportMetric import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_ACCEPT_TERMS_AND_CONDITIONS import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_CREATE_NEW_CHAT @@ -94,12 +100,20 @@ class RealDuckChatPixelsTest { private val statisticsUpdater: StatisticsUpdater = mock() private val duckAiMetricCollector: DuckAiMetricCollector = mock() private val mockTermsOfServiceHandler: DuckChatTermsOfServiceHandler = mock() + private val mockDuckChatInternal: DuckChatInternal = mock() + private val mockRegularTabRepository: TabRepository = mock() + private val mockFireTabRepository: TabRepository = mock() + private val mockTabRepositoryProvider: BrowserModeDataProvider = mock() + private val mockDuckAiTabSessionRepository: DuckAiTabSessionRepository = mock() + private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private lateinit var testee: RealDuckChatPixels @Before fun setup() = runTest { whenever(mockDuckChatFeatureRepository.sessionDeltaInMinutes()).thenReturn(1) + whenever(mockTabRepositoryProvider.forMode(BrowserMode.REGULAR)).thenReturn(mockRegularTabRepository) + whenever(mockTabRepositoryProvider.forMode(BrowserMode.FIRE)).thenReturn(mockFireTabRepository) testee = RealDuckChatPixels( pixel = mockPixel, @@ -109,6 +123,10 @@ class RealDuckChatPixelsTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = mockTermsOfServiceHandler, + tabRepositoryProvider = mockTabRepositoryProvider, + duckAiTabSessionRepository = mockDuckAiTabSessionRepository, + duckDuckGoUrlDetector = mockDuckDuckGoUrlDetector, + duckChatInternal = mockDuckChatInternal, ) } @@ -267,23 +285,35 @@ class RealDuckChatPixelsTest { } @Test - fun `when reportContextualPromptSubmittedWithContextNative then fires count and daily`() = runTest { + fun `when reportContextualPromptSubmittedWithContextNative then fires count and daily with page type and source`() = runTest { testee.reportContextualPromptSubmittedWithContextNative() advanceUntilIdle() - verify(mockPixel).fire(DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITH_CONTEXT_NATIVE_COUNT) - verify(mockPixel).fire(DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITH_CONTEXT_NATIVE_DAILY, type = Pixel.PixelType.Daily()) + // The contextual sheet is always entered by using it, so page_type and source are constants — + // no tab lookup involved. + val expectedParams = mapOf("page_type" to "contextual", "source" to "contextual_chat") + verify(mockPixel).fire(DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITH_CONTEXT_NATIVE_COUNT, parameters = expectedParams) + verify(mockPixel).fire( + DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITH_CONTEXT_NATIVE_DAILY, + parameters = expectedParams, + type = Pixel.PixelType.Daily(), + ) } @Test - fun `when reportContextualPromptSubmittedWithoutContextNative then fires count and daily`() = runTest { + fun `when reportContextualPromptSubmittedWithoutContextNative then fires count and daily with page type and source`() = runTest { testee.reportContextualPromptSubmittedWithoutContextNative() advanceUntilIdle() - verify(mockPixel).fire(DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITHOUT_CONTEXT_NATIVE_COUNT) - verify(mockPixel).fire(DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITHOUT_CONTEXT_NATIVE_DAILY, type = Pixel.PixelType.Daily()) + val expectedParams = mapOf("page_type" to "contextual", "source" to "contextual_chat") + verify(mockPixel).fire(DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITHOUT_CONTEXT_NATIVE_COUNT, parameters = expectedParams) + verify(mockPixel).fire( + DUCK_CHAT_CONTEXTUAL_PROMPT_SUBMITTED_WITHOUT_CONTEXT_NATIVE_DAILY, + parameters = expectedParams, + type = Pixel.PixelType.Daily(), + ) } @Test diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt index 72a00083b8c4..c9c4e3688fcd 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt @@ -38,6 +38,7 @@ import com.duckduckgo.browsermode.api.BrowserModeStateHolder import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.ActivePluginPoint import com.duckduckgo.duckchat.api.DuckAiFeatureState +import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.api.nativeinput.NativeInputStateProvider import com.duckduckgo.duckchat.api.nativeinput.NativeInputStatePublisher @@ -1689,7 +1690,12 @@ class NativeInputModeWidgetViewModelTest { advanceUntilIdle() viewModel.setSelectedTool(Tool.IMAGE_GENERATION.rawValue) - viewModel.fireSubmissionPixels(hasText = true, hasImageAttachment = true, hasFileAttachment = false) + viewModel.fireSubmissionPixels( + hasText = true, + hasImageAttachment = true, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "image_generation", @@ -1700,6 +1706,8 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, + tabId = tabId, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels).fireImageGenerationSubmitted(any()) verify(duckChatPixels, never()).fireWebSearchSubmitted(any()) @@ -1714,7 +1722,12 @@ class NativeInputModeWidgetViewModelTest { viewModel.configure(tabId = tabId, isDuckAiMode = true, isBottom = false) advanceUntilIdle() - viewModel.fireSubmissionPixels(hasText = true, hasImageAttachment = false, hasFileAttachment = false) + viewModel.fireSubmissionPixels( + hasText = true, + hasImageAttachment = false, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "none", @@ -1725,6 +1738,8 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, + tabId = tabId, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels, never()).fireImageGenerationSubmitted(any()) verify(duckChatPixels, never()).fireWebSearchSubmitted(any()) @@ -1739,7 +1754,12 @@ class NativeInputModeWidgetViewModelTest { viewModel.configure(tabId = "tab-A", isDuckAiMode = false, isBottom = false) advanceUntilIdle() - viewModel.fireSubmissionPixels(hasText = true, hasImageAttachment = false, hasFileAttachment = false) + viewModel.fireSubmissionPixels( + hasText = true, + hasImageAttachment = false, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "none", @@ -1750,6 +1770,8 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = NativeInputState.ToggleSelection.SEARCH, + tabId = "tab-A", + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) } @@ -1758,7 +1780,12 @@ class NativeInputModeWidgetViewModelTest { whenever(duckChatInternal.resolvedTogglePosition()).thenReturn(NativeInputState.ToggleSelection.SEARCH) advanceUntilIdle() - testee.fireSubmissionPixels(hasText = true, hasImageAttachment = false, hasFileAttachment = false) + testee.fireSubmissionPixels( + hasText = true, + hasImageAttachment = false, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "none", @@ -1769,6 +1796,8 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, + tabId = "test-tab", + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) } @@ -1782,7 +1811,12 @@ class NativeInputModeWidgetViewModelTest { advanceUntilIdle() viewModel.setSelectedTool(Tool.WEB_SEARCH.rawValue) - viewModel.fireSubmissionPixels(hasText = true, hasImageAttachment = false, hasFileAttachment = false) + viewModel.fireSubmissionPixels( + hasText = true, + hasImageAttachment = false, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "web_search", @@ -1793,6 +1827,8 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, + tabId = tabId, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels).fireWebSearchSubmitted(any()) verify(duckChatPixels, never()).fireImageGenerationSubmitted(any()) From 088f3dce9c2fc8ddc3e81861d252645aa0ffc5db Mon Sep 17 00:00:00 2001 From: Youssef Keyrouz Date: Fri, 21 Aug 2026 17:16:41 -0700 Subject: [PATCH 2/2] Address PR comments --- .../pixels/params_dictionary.json | 2 +- .../browser/nativeinput/NativeInputManager.kt | 1 + .../duckchat/impl/pixel/DuckChatPixels.kt | 69 ++++---- .../impl/ui/NativeInputModeWidgetViewModel.kt | 26 +++ .../views/NativeInputModeWidget.kt | 23 +++ .../RealDuckChatPixelsAttachmentsTest.kt | 3 - .../pixel/RealDuckChatPixelsPickerTest.kt | 3 - .../impl/pixel/RealDuckChatPixelsToolsTest.kt | 159 ++++++------------ .../impl/pixel/RealDuckChatPixelsTest.kt | 15 -- .../ui/NativeInputModeWidgetViewModelTest.kt | 9 + 10 files changed, 142 insertions(+), 168 deletions(-) diff --git a/PixelDefinitions/pixels/params_dictionary.json b/PixelDefinitions/pixels/params_dictionary.json index 795adb34d3a2..1ef8515fe88b 100644 --- a/PixelDefinitions/pixels/params_dictionary.json +++ b/PixelDefinitions/pixels/params_dictionary.json @@ -42,7 +42,7 @@ "key": "page_type", "type": "string", "description": "What the user was looking at when the prompt was submitted.", - "enum": ["ntp", "serp", "website", "duck_ai", "contextual", "unknown"] + "enum": ["ntp", "serp", "website", "duck_ai", "contextual"] }, "duckAiEntrySource": { "key": "source", diff --git a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt index 4d0471e9621c..41b8443d6d25 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/nativeinput/NativeInputManager.kt @@ -919,6 +919,7 @@ class RealNativeInputManager @Inject constructor( // Picker tied to whether the current tab is a Duck.ai page that already has a chatId (existing chat) or new chat. bindModelPickerEnabledSource(chatIdFlow.map { it == null }) bindChatIdSource(chatIdFlow) + bindCurrentUrlSource(currentTabUrl) bindInteractionLockSource(interactionLockSource) bindDuckAiFireButtonHighlightSource(duckAiFireButtonHighlightSource) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt index d2d01bff50e4..219313cd2ecc 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt @@ -16,15 +16,10 @@ package com.duckduckgo.duckchat.impl.pixel -import androidx.core.net.toUri -import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository -import com.duckduckgo.app.tabs.model.TabRepository -import com.duckduckgo.browsermode.api.BrowserMode -import com.duckduckgo.browsermode.api.BrowserModeDataProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter @@ -32,7 +27,6 @@ import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.ToggleSelection -import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.ModelTier import com.duckduckgo.duckchat.impl.ReportMetric import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_CREATE_NEW_CHAT @@ -144,6 +138,14 @@ enum class DuckChatPixelSurface(val value: String) { } } +enum class DuckChatPixelPageType(val value: String) { + NTP("ntp"), + SERP("serp"), + WEBSITE("website"), + DUCK_AI("duck_ai"), + CONTEXTUAL("contextual"), +} + interface DuckChatPixels { fun sendReportMetricPixel(reportMetric: ReportMetric, modelTier: ModelTier? = null, source: String? = null) fun reportOpen() @@ -216,6 +218,7 @@ interface DuckChatPixels { surface: DuckChatPixelSurface, defaultMode: ToggleSelection?, tabId: String?, + pageType: DuckChatPixelPageType, addressBarEntryPoint: DuckChatEntryPoint?, ) @@ -282,10 +285,7 @@ class RealDuckChatPixels @Inject constructor( private val statisticsUpdater: StatisticsUpdater, private val duckAiMetricCollector: DuckAiMetricCollector, private val termsOfServiceHandler: DuckChatTermsOfServiceHandler, - private val tabRepositoryProvider: BrowserModeDataProvider, private val duckAiTabSessionRepository: DuckAiTabSessionRepository, - private val duckChatInternal: DuckChatInternal, - private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, ) : DuckChatPixels { private fun fireCountAndDaily( @@ -299,6 +299,19 @@ class RealDuckChatPixels @Inject constructor( } } + /** For params that need a suspend lookup before they can be built. */ + private fun fireCountAndDaily( + count: DuckChatPixelName, + daily: DuckChatPixelName, + parameters: suspend () -> Map, + ) { + appCoroutineScope.launch(dispatcherProvider.io()) { + val params = parameters() + pixel.fire(count, parameters = params) + pixel.fire(daily, parameters = params, type = Pixel.PixelType.Daily()) + } + } + private fun surfaceParams(surface: DuckChatPixelSurface): Map = mapOf(DuckChatPixelParameters.SURFACE to surface.value) @@ -322,31 +335,6 @@ class RealDuckChatPixels @Inject constructor( ) } - /** What the user was looking at when a prompt was submitted. See [DuckChatPixelParameters.PROMPT_PAGE_TYPE]. */ - private suspend fun resolvePromptPageType( - surface: DuckChatPixelSurface, - tabId: String?, - ): String = when (surface) { - DuckChatPixelSurface.DUCK_AI -> "duck_ai" - DuckChatPixelSurface.CONTEXTUAL_CHAT -> "contextual" - DuckChatPixelSurface.ADDRESS_BAR -> { - val tab = tabId?.let { findTab(it) } - val url = tab?.url - when { - tab == null -> "unknown" - url.isNullOrBlank() -> "ntp" - duckChatInternal.isDuckChatUrl(url.toUri()) -> "duck_ai" - duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url) -> "serp" - else -> "website" - } - } - } - - /** [tabId] may belong to either browsing modes Regular or Fire mode (They are backed by separate databases). */ - private suspend fun findTab(tabId: String) = - tabRepositoryProvider.forMode(BrowserMode.REGULAR).getTab(tabId) - ?: tabRepositoryProvider.forMode(BrowserMode.FIRE).getTab(tabId) - /** * The `source` for a prompt submission. Inside an existing Duck.ai chat, the entry point is carried forward * from whatever was recorded for [tabId] when that chat was entered. @@ -820,12 +808,15 @@ class RealDuckChatPixels @Inject constructor( surface: DuckChatPixelSurface, defaultMode: ToggleSelection?, tabId: String?, + pageType: DuckChatPixelPageType, addressBarEntryPoint: DuckChatEntryPoint?, ) { - appCoroutineScope.launch(dispatcherProvider.io()) { - val pageType = resolvePromptPageType(surface, tabId) + fireCountAndDaily( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_DAILY, + ) { val source = resolveEntrySource(surface, tabId, addressBarEntryPoint) - val params = buildMap { + buildMap { put(DuckChatPixelParameters.SELECTED_TOOL, selectedTool) modelId?.let { put(DuckChatPixelParameters.MODEL_ID, it) } reasoningEffort?.let { put(DuckChatPixelParameters.REASONING_EFFORT, it) } @@ -836,11 +827,9 @@ class RealDuckChatPixels @Inject constructor( defaultMode ?.takeIf { surface == DuckChatPixelSurface.ADDRESS_BAR } ?.let { put(DuckChatPixelParameters.DEFAULT_MODE, it.pixelValue()) } - put(DuckChatPixelParameters.PROMPT_PAGE_TYPE, pageType) + put(DuckChatPixelParameters.PROMPT_PAGE_TYPE, pageType.value) source?.let { put(DuckChatPixelParameters.ENTRY_SOURCE, it) } } - pixel.fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) - pixel.fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_DAILY, parameters = params, type = Pixel.PixelType.Daily()) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt index 834aae34be85..c3f99e5a899e 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModel.kt @@ -16,9 +16,11 @@ package com.duckduckgo.duckchat.impl.ui +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily @@ -56,6 +58,8 @@ import com.duckduckgo.duckchat.impl.models.ReasoningResolver import com.duckduckgo.duckchat.impl.models.Tool import com.duckduckgo.duckchat.impl.nativeinput.NativeInputPlugin import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName +import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType +import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelParameters import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.ui.nativeinput.suggestions.ChatSuggestion @@ -97,6 +101,7 @@ data class ChatTabSuggestions( @ContributesViewModel(ViewScope::class) class NativeInputModeWidgetViewModel @Inject constructor( private val duckChatInternal: DuckChatInternal, + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, duckAiFeatureState: DuckAiFeatureState, subscriptions: Subscriptions, private val pendingNativePromptStore: PendingNativePromptStore, @@ -154,6 +159,10 @@ class NativeInputModeWidgetViewModel @Inject constructor( private val currentChat = MutableStateFlow(null) private var currentChatJob: Job? = null + // Kept in sync via bindCurrentUrlSource so submission pixels can read the tab's URL + // synchronously — a suspend re-read at fire time would race the navigation the submission triggers. + private var latestTabUrl: String? = null + // Defensive buffer for the (rare) case where setActiveChatId fires before configure has set // activeTabId. Replayed inside configure / configureContextual when activeTabId becomes known. private var pendingChatId: String? = null @@ -282,6 +291,7 @@ class NativeInputModeWidgetViewModel @Inject constructor( surface = surface, defaultMode = resolvedTogglePositionIfVisible(inputState), tabId = activeTabId.value, + pageType = resolvePageType(surface, latestTabUrl), addressBarEntryPoint = addressBarEntryPoint, ) when (tool) { @@ -556,6 +566,22 @@ class NativeInputModeWidgetViewModel @Inject constructor( } } + fun setActiveTabUrl(url: String?) { + latestTabUrl = url + } + + /** What the user is looking at. See [DuckChatPixelParameters.PROMPT_PAGE_TYPE]. */ + private fun resolvePageType(surface: DuckChatPixelSurface, currentUrl: String?): DuckChatPixelPageType = when (surface) { + DuckChatPixelSurface.DUCK_AI -> DuckChatPixelPageType.DUCK_AI + DuckChatPixelSurface.CONTEXTUAL_CHAT -> DuckChatPixelPageType.CONTEXTUAL + DuckChatPixelSurface.ADDRESS_BAR -> when { + currentUrl.isNullOrBlank() -> DuckChatPixelPageType.NTP + duckChatInternal.isDuckChatUrl(currentUrl.toUri()) -> DuckChatPixelPageType.DUCK_AI + duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(currentUrl) -> DuckChatPixelPageType.SERP + else -> DuckChatPixelPageType.WEBSITE + } + } + fun storePendingPrompt( query: String, modelId: String?, diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt index 70239c9e8d38..a5857de33e5d 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/nativeinput/views/NativeInputModeWidget.kt @@ -199,6 +199,9 @@ interface NativeInputWidget { */ fun bindChatIdSource(source: Flow) + /** Binds a reactive source of the underlying tab's current URL. */ + fun bindCurrentUrlSource(source: Flow) + /** Binds a reactive source for the onboarding interaction lock (see [InteractionLock]). */ fun bindInteractionLockSource(source: Flow) @@ -310,6 +313,8 @@ class NativeInputModeWidget @JvmOverloads constructor( private var modelPickerEnabledSource: Flow? = null private var chatIdJob: Job? = null private var chatIdSource: Flow? = null + private var currentUrlJob: Job? = null + private var currentUrlSource: Flow? = null private var interactionLockJob: Job? = null private var interactionLockSource: Flow? = null private var duckAiFireButtonHighlightJob: Job? = null @@ -691,6 +696,7 @@ class NativeInputModeWidget @JvmOverloads constructor( setupPlugins() observeModelPickerEnabledSource() observeChatIdSource() + observeCurrentUrlSource() observeInteractionLockSource() observeDuckAiFireButtonHighlightSource() applyNativeStyling() @@ -850,6 +856,8 @@ class NativeInputModeWidget @JvmOverloads constructor( modelPickerEnabledJob = null chatIdJob?.cancel() chatIdJob = null + currentUrlJob?.cancel() + currentUrlJob = null interactionLockJob?.cancel() interactionLockJob = null duckAiFireButtonHighlightJob?.cancel() @@ -1573,6 +1581,21 @@ class NativeInputModeWidget @JvmOverloads constructor( .launchIn(scope) } + override fun bindCurrentUrlSource(source: Flow) { + currentUrlSource = source + if (isAttachedToWindow) observeCurrentUrlSource() + } + + private fun observeCurrentUrlSource() { + val source = currentUrlSource ?: return + val scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: return + currentUrlJob?.cancel() + currentUrlJob = source + .distinctUntilChanged() + .onEach { viewModel.setActiveTabUrl(it) } + .launchIn(scope) + } + override fun bindInteractionLockSource(source: Flow) { interactionLockSource = source if (isAttachedToWindow) observeInteractionLockSource() diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsAttachmentsTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsAttachmentsTest.kt index 07d6a7c6e021..f5bd3fb52b9b 100644 --- a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsAttachmentsTest.kt +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsAttachmentsTest.kt @@ -47,10 +47,7 @@ class RealDuckChatPixelsAttachmentsTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = termsOfServiceHandler, - tabRepositoryProvider = mock(), duckAiTabSessionRepository = mock(), - duckDuckGoUrlDetector = mock(), - duckChatInternal = mock(), ) @Test diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsPickerTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsPickerTest.kt index 3e4203ab76bb..3a33cb5e0061 100644 --- a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsPickerTest.kt +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsPickerTest.kt @@ -47,10 +47,7 @@ class RealDuckChatPixelsPickerTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = termsOfServiceHandler, - tabRepositoryProvider = mock(), duckAiTabSessionRepository = mock(), - duckDuckGoUrlDetector = mock(), - duckChatInternal = mock(), ) @Test diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsToolsTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsToolsTest.kt index b0e69e9de3b4..9dbe78888f25 100644 --- a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsToolsTest.kt +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsToolsTest.kt @@ -17,26 +17,19 @@ package com.duckduckgo.duckchat.impl.pixel import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.tabs.model.TabEntity -import com.duckduckgo.app.tabs.model.TabRepository -import com.duckduckgo.browsermode.api.BrowserMode -import com.duckduckgo.browsermode.api.BrowserModeDataProvider +import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.ToggleSelection -import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.helper.DuckChatTermsOfServiceHandler import com.duckduckgo.duckchat.impl.metric.DuckAiMetricCollector import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository import kotlinx.coroutines.test.runTest -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -52,17 +45,7 @@ class RealDuckChatPixelsToolsTest { private val statisticsUpdater: StatisticsUpdater = mock() private val duckAiMetricCollector: DuckAiMetricCollector = mock() private val termsOfServiceHandler: DuckChatTermsOfServiceHandler = mock() - private val duckChatInternal: DuckChatInternal = mock() - private val regularTabRepository: TabRepository = mock() - private val fireTabRepository: TabRepository = mock() - private val tabRepositoryProvider: BrowserModeDataProvider = mock() - private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() - - @Before - fun setup() { - whenever(tabRepositoryProvider.forMode(BrowserMode.REGULAR)).thenReturn(regularTabRepository) - whenever(tabRepositoryProvider.forMode(BrowserMode.FIRE)).thenReturn(fireTabRepository) - } + private val duckAiTabSessionRepository: DuckAiTabSessionRepository = mock() private val testee = RealDuckChatPixels( pixel = pixel, @@ -72,10 +55,7 @@ class RealDuckChatPixelsToolsTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = termsOfServiceHandler, - tabRepositoryProvider = tabRepositoryProvider, - duckAiTabSessionRepository = mock(), - duckDuckGoUrlDetector = duckDuckGoUrlDetector, - duckChatInternal = duckChatInternal, + duckAiTabSessionRepository = duckAiTabSessionRepository, ) private val surfaceParams = mapOf(DuckChatPixelParameters.SURFACE to "contextual_chat") @@ -176,6 +156,7 @@ class RealDuckChatPixelsToolsTest { surface = DuckChatPixelSurface.CONTEXTUAL_CHAT, defaultMode = null, tabId = null, + pageType = DuckChatPixelPageType.CONTEXTUAL, addressBarEntryPoint = null, ) @@ -199,7 +180,9 @@ class RealDuckChatPixelsToolsTest { } @Test - fun whenPromptSubmittedWithNullModelAndReasoningThenOmitsThoseParams() = runTest { + fun whenPromptSubmittedFromDuckAiChatWithStoredEntryThenSourceIsTheStoredValue() = runTest { + whenever(duckAiTabSessionRepository.getEntryPointSource("tab1")).thenReturn("chat_history_open_chat") + testee.firePromptSubmitted( selectedTool = "none", modelId = null, @@ -207,9 +190,10 @@ class RealDuckChatPixelsToolsTest { hasImageAttachment = false, hasFileAttachment = false, hasText = true, - surface = DuckChatPixelSurface.ADDRESS_BAR, + surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, - tabId = null, + tabId = "tab1", + pageType = DuckChatPixelPageType.DUCK_AI, addressBarEntryPoint = null, ) @@ -218,8 +202,9 @@ class RealDuckChatPixelsToolsTest { DuckChatPixelParameters.HAS_IMAGE_ATTACHMENT to "false", DuckChatPixelParameters.HAS_FILE_ATTACHMENT to "false", DuckChatPixelParameters.HAS_TEXT to "true", - DuckChatPixelParameters.SURFACE to "address_bar", - DuckChatPixelParameters.PROMPT_PAGE_TYPE to "unknown", + DuckChatPixelParameters.SURFACE to "duck_ai", + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "duck_ai", + DuckChatPixelParameters.ENTRY_SOURCE to "chat_history_open_chat", ) verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) verify(pixel).fire( @@ -230,8 +215,8 @@ class RealDuckChatPixelsToolsTest { } @Test - fun whenPromptSubmittedFromAddressBarOnBlankTabThenPageTypeIsNtp() = runTest { - whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = null)) + fun whenPromptSubmittedFromDuckAiChatWithNothingStoredThenSourceIsOmitted() = runTest { + whenever(duckAiTabSessionRepository.getEntryPointSource("tab1")).thenReturn(null) testee.firePromptSubmitted( selectedTool = "none", @@ -240,49 +225,31 @@ class RealDuckChatPixelsToolsTest { hasImageAttachment = false, hasFileAttachment = false, hasText = true, - surface = DuckChatPixelSurface.ADDRESS_BAR, + surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, tabId = "tab1", - addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, - ) - - verify(pixel).fire( - DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, - parameters = promptSubmittedAddressBarParams(pageType = "ntp"), + pageType = DuckChatPixelPageType.DUCK_AI, + addressBarEntryPoint = null, ) - } - - @Test - fun whenPromptSubmittedFromAddressBarOnWebsiteThenPageTypeIsWebsite() = runTest { - whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = "https://example.com")) - whenever(duckChatInternal.isDuckChatUrl(any())).thenReturn(false) - whenever(duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(false) - testee.firePromptSubmitted( - selectedTool = "none", - modelId = null, - reasoningEffort = null, - hasImageAttachment = false, - hasFileAttachment = false, - hasText = true, - surface = DuckChatPixelSurface.ADDRESS_BAR, - defaultMode = null, - tabId = "tab1", - addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + val params = mapOf( + DuckChatPixelParameters.SELECTED_TOOL to "none", + DuckChatPixelParameters.HAS_IMAGE_ATTACHMENT to "false", + DuckChatPixelParameters.HAS_FILE_ATTACHMENT to "false", + DuckChatPixelParameters.HAS_TEXT to "true", + DuckChatPixelParameters.SURFACE to "duck_ai", + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "duck_ai", ) - + verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) verify(pixel).fire( - DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, - parameters = promptSubmittedAddressBarParams(pageType = "website"), + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_DAILY, + parameters = params, + type = Pixel.PixelType.Daily(), ) } @Test - fun whenPromptSubmittedFromAddressBarOnSerpThenPageTypeIsSerp() = runTest { - whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = "https://duckduckgo.com/?q=test")) - whenever(duckChatInternal.isDuckChatUrl(any())).thenReturn(false) - whenever(duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true) - + fun whenPromptSubmittedWithNullModelAndReasoningThenOmitsThoseParams() = runTest { testee.firePromptSubmitted( selectedTool = "none", modelId = null, @@ -292,50 +259,29 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, - tabId = "tab1", - addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, - ) - - verify(pixel).fire( - DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, - parameters = promptSubmittedAddressBarParams(pageType = "serp"), + tabId = null, + pageType = DuckChatPixelPageType.NTP, + addressBarEntryPoint = null, ) - } - @Test - fun whenPromptSubmittedFromAddressBarAndTabExistsOnlyInFireModeThenPageTypeIsResolvedFromFireRepository() = runTest { - // The tab isn't in the Regular-mode tabs table at all — it lives in Fire mode's own database. - whenever(regularTabRepository.getTab("fire-tab")).thenReturn(null) - whenever(fireTabRepository.getTab("fire-tab")).thenReturn(TabEntity(tabId = "fire-tab", url = "https://example.com")) - whenever(duckChatInternal.isDuckChatUrl(any())).thenReturn(false) - whenever(duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(false) - - testee.firePromptSubmitted( - selectedTool = "none", - modelId = null, - reasoningEffort = null, - hasImageAttachment = false, - hasFileAttachment = false, - hasText = true, - surface = DuckChatPixelSurface.ADDRESS_BAR, - defaultMode = null, - tabId = "fire-tab", - addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + val params = mapOf( + DuckChatPixelParameters.SELECTED_TOOL to "none", + DuckChatPixelParameters.HAS_IMAGE_ATTACHMENT to "false", + DuckChatPixelParameters.HAS_FILE_ATTACHMENT to "false", + DuckChatPixelParameters.HAS_TEXT to "true", + DuckChatPixelParameters.SURFACE to "address_bar", + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "ntp", ) - - // Must resolve to the tab's real page type, not fall back to "ntp" just because it was - // missing from the Regular-mode repository. + verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) verify(pixel).fire( - DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, - parameters = promptSubmittedAddressBarParams(pageType = "website"), + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_DAILY, + parameters = params, + type = Pixel.PixelType.Daily(), ) } @Test - fun whenPromptSubmittedFromAddressBarAndTabIsNotFoundInEitherModeThenPageTypeIsUnknown() = runTest { - whenever(regularTabRepository.getTab("missing-tab")).thenReturn(null) - whenever(fireTabRepository.getTab("missing-tab")).thenReturn(null) - + fun whenPromptSubmittedFromAddressBarThenPageTypeValueIsForwardedAsIs() = runTest { testee.firePromptSubmitted( selectedTool = "none", modelId = null, @@ -345,20 +291,19 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, - tabId = "missing-tab", + tabId = "tab1", + pageType = DuckChatPixelPageType.WEBSITE, addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(pixel).fire( DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, - parameters = promptSubmittedAddressBarParams(pageType = "unknown"), + parameters = promptSubmittedAddressBarParams(pageType = "website"), ) } @Test fun whenPromptSubmittedFromAddressBarWithVoiceEntryPointThenSourceIsVoice() = runTest { - whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = null)) - testee.firePromptSubmitted( selectedTool = "none", modelId = null, @@ -369,6 +314,7 @@ class RealDuckChatPixelsToolsTest { surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, tabId = "tab1", + pageType = DuckChatPixelPageType.NTP, addressBarEntryPoint = DuckChatEntryPoint.VOICE, ) @@ -380,8 +326,6 @@ class RealDuckChatPixelsToolsTest { @Test fun whenPromptSubmittedFromAddressBarWithNoEntryPointThenSourceIsOmitted() = runTest { - whenever(regularTabRepository.getTab("tab1")).thenReturn(TabEntity(tabId = "tab1", url = null)) - testee.firePromptSubmitted( selectedTool = "none", modelId = null, @@ -392,6 +336,7 @@ class RealDuckChatPixelsToolsTest { surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, tabId = "tab1", + pageType = DuckChatPixelPageType.NTP, addressBarEntryPoint = null, ) @@ -423,6 +368,7 @@ class RealDuckChatPixelsToolsTest { surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = ToggleSelection.DUCK_AI, tabId = null, + pageType = DuckChatPixelPageType.NTP, addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) @@ -433,7 +379,7 @@ class RealDuckChatPixelsToolsTest { DuckChatPixelParameters.HAS_TEXT to "true", DuckChatPixelParameters.SURFACE to "address_bar", DuckChatPixelParameters.DEFAULT_MODE to "duck_ai", - DuckChatPixelParameters.PROMPT_PAGE_TYPE to "unknown", + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "ntp", DuckChatPixelParameters.ENTRY_SOURCE to "address_bar_prompt", ) verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) @@ -458,6 +404,7 @@ class RealDuckChatPixelsToolsTest { surface = DuckChatPixelSurface.CONTEXTUAL_CHAT, defaultMode = ToggleSelection.DUCK_AI, tabId = null, + pageType = DuckChatPixelPageType.CONTEXTUAL, addressBarEntryPoint = null, ) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsTest.kt index c8f5c59a5773..a5368b3cc4b0 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/pixel/RealDuckChatPixelsTest.kt @@ -16,17 +16,12 @@ package com.duckduckgo.duckchat.impl.pixel -import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.tabs.model.DuckAiTabSessionRepository -import com.duckduckgo.app.tabs.model.TabRepository -import com.duckduckgo.browsermode.api.BrowserMode -import com.duckduckgo.browsermode.api.BrowserModeDataProvider import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.ToggleSelection -import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.ReportMetric import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_ACCEPT_TERMS_AND_CONDITIONS import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_CREATE_NEW_CHAT @@ -100,20 +95,13 @@ class RealDuckChatPixelsTest { private val statisticsUpdater: StatisticsUpdater = mock() private val duckAiMetricCollector: DuckAiMetricCollector = mock() private val mockTermsOfServiceHandler: DuckChatTermsOfServiceHandler = mock() - private val mockDuckChatInternal: DuckChatInternal = mock() - private val mockRegularTabRepository: TabRepository = mock() - private val mockFireTabRepository: TabRepository = mock() - private val mockTabRepositoryProvider: BrowserModeDataProvider = mock() private val mockDuckAiTabSessionRepository: DuckAiTabSessionRepository = mock() - private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private lateinit var testee: RealDuckChatPixels @Before fun setup() = runTest { whenever(mockDuckChatFeatureRepository.sessionDeltaInMinutes()).thenReturn(1) - whenever(mockTabRepositoryProvider.forMode(BrowserMode.REGULAR)).thenReturn(mockRegularTabRepository) - whenever(mockTabRepositoryProvider.forMode(BrowserMode.FIRE)).thenReturn(mockFireTabRepository) testee = RealDuckChatPixels( pixel = mockPixel, @@ -123,10 +111,7 @@ class RealDuckChatPixelsTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = mockTermsOfServiceHandler, - tabRepositoryProvider = mockTabRepositoryProvider, duckAiTabSessionRepository = mockDuckAiTabSessionRepository, - duckDuckGoUrlDetector = mockDuckDuckGoUrlDetector, - duckChatInternal = mockDuckChatInternal, ) } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt index c9c4e3688fcd..72f98504e98c 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt @@ -19,6 +19,7 @@ package com.duckduckgo.duckchat.impl.ui import android.content.Context import android.view.View import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.tabs.model.TabEntity @@ -59,6 +60,7 @@ import com.duckduckgo.duckchat.impl.nativeinput.NativeInputHost import com.duckduckgo.duckchat.impl.nativeinput.NativeInputPlugin import com.duckduckgo.duckchat.impl.nativeinput.RealNativeInputStateStore import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName +import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.ui.nativeinput.suggestions.ChatSuggestion @@ -111,6 +113,7 @@ class NativeInputModeWidgetViewModelTest { val coroutineRule = CoroutineTestRule() private val duckChatInternal: DuckChatInternal = mock() + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private val duckAiFeatureState: DuckAiFeatureState = mock() private val subscriptions: Subscriptions = mock() private val pendingNativePromptStore: PendingNativePromptStore = mock() @@ -182,6 +185,7 @@ class NativeInputModeWidgetViewModelTest { fakePlugins = plugins return NativeInputModeWidgetViewModel( duckChatInternal = duckChatInternal, + duckDuckGoUrlDetector = duckDuckGoUrlDetector, duckAiFeatureState = duckAiFeatureState, subscriptions = subscriptions, pendingNativePromptStore = pendingNativePromptStore, @@ -1707,6 +1711,7 @@ class NativeInputModeWidgetViewModelTest { surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, tabId = tabId, + pageType = DuckChatPixelPageType.DUCK_AI, addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels).fireImageGenerationSubmitted(any()) @@ -1739,6 +1744,7 @@ class NativeInputModeWidgetViewModelTest { surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, tabId = tabId, + pageType = DuckChatPixelPageType.DUCK_AI, addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels, never()).fireImageGenerationSubmitted(any()) @@ -1771,6 +1777,7 @@ class NativeInputModeWidgetViewModelTest { surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = NativeInputState.ToggleSelection.SEARCH, tabId = "tab-A", + pageType = DuckChatPixelPageType.NTP, addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) } @@ -1797,6 +1804,7 @@ class NativeInputModeWidgetViewModelTest { surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, tabId = "test-tab", + pageType = DuckChatPixelPageType.NTP, addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) } @@ -1828,6 +1836,7 @@ class NativeInputModeWidgetViewModelTest { surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, tabId = tabId, + pageType = DuckChatPixelPageType.DUCK_AI, addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels).fireWebSearchSubmitted(any())