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..1ef8515fe88b 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"] + }, + "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..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 @@ -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) } }, @@ -920,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/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..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 @@ -19,6 +19,7 @@ package com.duckduckgo.duckchat.impl.pixel 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.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter @@ -137,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() @@ -208,6 +217,9 @@ interface DuckChatPixels { hasText: Boolean, surface: DuckChatPixelSurface, defaultMode: ToggleSelection?, + tabId: String?, + pageType: DuckChatPixelPageType, + addressBarEntryPoint: DuckChatEntryPoint?, ) /** Prompt submitted while the unified input is in a Duck.ai chat context. Fires alongside [firePromptSubmitted]. */ @@ -273,6 +285,7 @@ class RealDuckChatPixels @Inject constructor( private val statisticsUpdater: StatisticsUpdater, private val duckAiMetricCollector: DuckAiMetricCollector, private val termsOfServiceHandler: DuckChatTermsOfServiceHandler, + private val duckAiTabSessionRepository: DuckAiTabSessionRepository, ) : DuckChatPixels { private fun fireCountAndDaily( @@ -286,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) @@ -309,6 +335,20 @@ class RealDuckChatPixels @Inject constructor( ) } + /** + * 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 +578,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 +603,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 +807,30 @@ class RealDuckChatPixels @Inject constructor( hasText: Boolean, surface: DuckChatPixelSurface, defaultMode: ToggleSelection?, + tabId: String?, + pageType: DuckChatPixelPageType, + 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()) } - } fireCountAndDaily( DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_DAILY, - params, - ) + ) { + val source = resolveEntrySource(surface, tabId, addressBarEntryPoint) + 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.value) + source?.let { put(DuckChatPixelParameters.ENTRY_SOURCE, it) } + } + } } override fun fireSentPromptInChat(surface: DuckChatPixelSurface) = fireCountAndDaily( @@ -1375,6 +1437,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 +1729,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..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 @@ -262,6 +271,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 +290,9 @@ class NativeInputModeWidgetViewModel @Inject constructor( hasText = hasText, surface = surface, defaultMode = resolvedTogglePositionIfVisible(inputState), + tabId = activeTabId.value, + pageType = resolvePageType(surface, latestTabUrl), + addressBarEntryPoint = addressBarEntryPoint, ) when (tool) { Tool.IMAGE_GENERATION -> duckChatPixels.fireImageGenerationSubmitted(surface) @@ -553,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 354cb557042b..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 @@ -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 @@ -193,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) @@ -304,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 @@ -430,6 +441,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 @@ -684,6 +696,7 @@ class NativeInputModeWidget @JvmOverloads constructor( setupPlugins() observeModelPickerEnabledSource() observeChatIdSource() + observeCurrentUrlSource() observeInteractionLockSource() observeDuckAiFireButtonHighlightSource() applyNativeStyling() @@ -843,6 +856,8 @@ class NativeInputModeWidget @JvmOverloads constructor( modelPickerEnabledJob = null chatIdJob?.cancel() chatIdJob = null + currentUrlJob?.cancel() + currentUrlJob = null interactionLockJob?.cancel() interactionLockJob = null duckAiFireButtonHighlightJob?.cancel() @@ -1380,6 +1395,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. @@ -1565,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 40c6fec868f3..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,6 +47,7 @@ class RealDuckChatPixelsAttachmentsTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = termsOfServiceHandler, + duckAiTabSessionRepository = 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..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,6 +47,7 @@ class RealDuckChatPixelsPickerTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = termsOfServiceHandler, + duckAiTabSessionRepository = 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..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 @@ -16,9 +16,12 @@ package com.duckduckgo.duckchat.impl.pixel +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel +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.helper.DuckChatTermsOfServiceHandler import com.duckduckgo.duckchat.impl.metric.DuckAiMetricCollector @@ -26,9 +29,12 @@ import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +@RunWith(AndroidJUnit4::class) class RealDuckChatPixelsToolsTest { @get:Rule @@ -39,6 +45,7 @@ class RealDuckChatPixelsToolsTest { private val statisticsUpdater: StatisticsUpdater = mock() private val duckAiMetricCollector: DuckAiMetricCollector = mock() private val termsOfServiceHandler: DuckChatTermsOfServiceHandler = mock() + private val duckAiTabSessionRepository: DuckAiTabSessionRepository = mock() private val testee = RealDuckChatPixels( pixel = pixel, @@ -48,6 +55,7 @@ class RealDuckChatPixelsToolsTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = termsOfServiceHandler, + duckAiTabSessionRepository = duckAiTabSessionRepository, ) private val surfaceParams = mapOf(DuckChatPixelParameters.SURFACE to "contextual_chat") @@ -147,6 +155,9 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.CONTEXTUAL_CHAT, defaultMode = null, + tabId = null, + pageType = DuckChatPixelPageType.CONTEXTUAL, + addressBarEntryPoint = null, ) val params = mapOf( @@ -157,6 +168,77 @@ 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( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_DAILY, + parameters = params, + type = Pixel.PixelType.Daily(), + ) + } + + @Test + fun whenPromptSubmittedFromDuckAiChatWithStoredEntryThenSourceIsTheStoredValue() = runTest { + whenever(duckAiTabSessionRepository.getEntryPointSource("tab1")).thenReturn("chat_history_open_chat") + + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.DUCK_AI, + defaultMode = null, + tabId = "tab1", + pageType = DuckChatPixelPageType.DUCK_AI, + addressBarEntryPoint = null, + ) + + 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", + DuckChatPixelParameters.ENTRY_SOURCE to "chat_history_open_chat", + ) + verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_DAILY, + parameters = params, + type = Pixel.PixelType.Daily(), + ) + } + + @Test + fun whenPromptSubmittedFromDuckAiChatWithNothingStoredThenSourceIsOmitted() = runTest { + whenever(duckAiTabSessionRepository.getEntryPointSource("tab1")).thenReturn(null) + + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.DUCK_AI, + defaultMode = null, + tabId = "tab1", + pageType = DuckChatPixelPageType.DUCK_AI, + addressBarEntryPoint = null, + ) + + 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( @@ -177,6 +259,9 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, + tabId = null, + pageType = DuckChatPixelPageType.NTP, + addressBarEntryPoint = null, ) val params = mapOf( @@ -185,6 +270,7 @@ class RealDuckChatPixelsToolsTest { DuckChatPixelParameters.HAS_FILE_ATTACHMENT to "false", DuckChatPixelParameters.HAS_TEXT to "true", DuckChatPixelParameters.SURFACE to "address_bar", + DuckChatPixelParameters.PROMPT_PAGE_TYPE to "ntp", ) verify(pixel).fire(DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, parameters = params) verify(pixel).fire( @@ -194,6 +280,82 @@ class RealDuckChatPixelsToolsTest { ) } + @Test + fun whenPromptSubmittedFromAddressBarThenPageTypeValueIsForwardedAsIs() = runTest { + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "tab1", + pageType = DuckChatPixelPageType.WEBSITE, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) + + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + parameters = promptSubmittedAddressBarParams(pageType = "website"), + ) + } + + @Test + fun whenPromptSubmittedFromAddressBarWithVoiceEntryPointThenSourceIsVoice() = runTest { + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "tab1", + pageType = DuckChatPixelPageType.NTP, + addressBarEntryPoint = DuckChatEntryPoint.VOICE, + ) + + verify(pixel).fire( + DuckChatPixelName.DUCK_CHAT_UNIFIED_INPUT_PROMPT_SUBMITTED_COUNT, + parameters = promptSubmittedAddressBarParams(pageType = "ntp", source = "voice"), + ) + } + + @Test + fun whenPromptSubmittedFromAddressBarWithNoEntryPointThenSourceIsOmitted() = runTest { + testee.firePromptSubmitted( + selectedTool = "none", + modelId = null, + reasoningEffort = null, + hasImageAttachment = false, + hasFileAttachment = false, + hasText = true, + surface = DuckChatPixelSurface.ADDRESS_BAR, + defaultMode = null, + tabId = "tab1", + pageType = DuckChatPixelPageType.NTP, + 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 +367,9 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = ToggleSelection.DUCK_AI, + tabId = null, + pageType = DuckChatPixelPageType.NTP, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) val params = mapOf( @@ -214,6 +379,8 @@ class RealDuckChatPixelsToolsTest { DuckChatPixelParameters.HAS_TEXT to "true", DuckChatPixelParameters.SURFACE to "address_bar", DuckChatPixelParameters.DEFAULT_MODE to "duck_ai", + 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) verify(pixel).fire( @@ -236,6 +403,9 @@ class RealDuckChatPixelsToolsTest { hasText = true, surface = DuckChatPixelSurface.CONTEXTUAL_CHAT, defaultMode = ToggleSelection.DUCK_AI, + tabId = null, + pageType = DuckChatPixelPageType.CONTEXTUAL, + addressBarEntryPoint = null, ) val params = mapOf( @@ -244,6 +414,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..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 @@ -18,6 +18,7 @@ package com.duckduckgo.duckchat.impl.pixel import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel +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 @@ -94,6 +95,7 @@ class RealDuckChatPixelsTest { private val statisticsUpdater: StatisticsUpdater = mock() private val duckAiMetricCollector: DuckAiMetricCollector = mock() private val mockTermsOfServiceHandler: DuckChatTermsOfServiceHandler = mock() + private val mockDuckAiTabSessionRepository: DuckAiTabSessionRepository = mock() private lateinit var testee: RealDuckChatPixels @@ -109,6 +111,7 @@ class RealDuckChatPixelsTest { statisticsUpdater = statisticsUpdater, duckAiMetricCollector = duckAiMetricCollector, termsOfServiceHandler = mockTermsOfServiceHandler, + duckAiTabSessionRepository = mockDuckAiTabSessionRepository, ) } @@ -267,23 +270,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..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 @@ -38,6 +39,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 @@ -58,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 @@ -110,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() @@ -181,6 +185,7 @@ class NativeInputModeWidgetViewModelTest { fakePlugins = plugins return NativeInputModeWidgetViewModel( duckChatInternal = duckChatInternal, + duckDuckGoUrlDetector = duckDuckGoUrlDetector, duckAiFeatureState = duckAiFeatureState, subscriptions = subscriptions, pendingNativePromptStore = pendingNativePromptStore, @@ -1689,7 +1694,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 +1710,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, + tabId = tabId, + pageType = DuckChatPixelPageType.DUCK_AI, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels).fireImageGenerationSubmitted(any()) verify(duckChatPixels, never()).fireWebSearchSubmitted(any()) @@ -1714,7 +1727,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 +1743,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, + tabId = tabId, + pageType = DuckChatPixelPageType.DUCK_AI, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels, never()).fireImageGenerationSubmitted(any()) verify(duckChatPixels, never()).fireWebSearchSubmitted(any()) @@ -1739,7 +1760,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 +1776,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = NativeInputState.ToggleSelection.SEARCH, + tabId = "tab-A", + pageType = DuckChatPixelPageType.NTP, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) } @@ -1758,7 +1787,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 +1803,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, + tabId = "test-tab", + pageType = DuckChatPixelPageType.NTP, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) } @@ -1782,7 +1819,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 +1835,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, + tabId = tabId, + pageType = DuckChatPixelPageType.DUCK_AI, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels).fireWebSearchSubmitted(any()) verify(duckChatPixels, never()).fireImageGenerationSubmitted(any())