diff --git a/PixelDefinitions/pixels/definitions/duck_chat.json5 b/PixelDefinitions/pixels/definitions/duck_chat.json5
index 80a390c87170..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",
@@ -439,6 +439,24 @@
}
]
},
+ "m_aichat_entry_point": {
+ "description": "Fires when an active user action enters Duck.ai, with the initiating surface and navigation context.",
+ "owners": ["YoussefKeyrouz"],
+ "triggers": ["other"],
+ "suffixes": ["first_daily_count", "form_factor"],
+ "parameters": [
+ "appVersion",
+ "duckAiEntrySource",
+ { "key": "duck_ai_enabled", "type": "boolean", "description": "Whether Duck.ai is enabled by feature and user settings." },
+ {
+ "key": "input_screen_enabled",
+ "type": "boolean",
+ "description": "Whether the Search and Duck.ai input capability is enabled."
+ },
+ { "key": "opens_new_tab", "type": "boolean", "description": "Whether this entry opens a new browser tab." },
+ { "key": "has_prompt", "type": "boolean", "description": "Whether a non-blank prompt is automatically submitted on entry." }
+ ]
+ },
"m_aichat_duck_ai_direct_navigation_count": {
"description": "User navigated directly to the Duck.ai URL by typing it in the omnibar",
"owners": ["malmstein"],
@@ -448,9 +466,13 @@
"appVersion",
{
"key": "duck_ai_enabled",
- "type": "string",
- "description": "Whether the Duck.ai global setting is enabled",
- "enum": ["true", "false"]
+ "type": "boolean",
+ "description": "Whether the Duck.ai global setting is enabled"
+ },
+ {
+ "key": "input_screen_enabled",
+ "type": "boolean",
+ "description": "Whether the Search and Duck.ai input capability is enabled."
}
]
},
@@ -463,9 +485,13 @@
"appVersion",
{
"key": "duck_ai_enabled",
- "type": "string",
- "description": "Whether the Duck.ai global setting is enabled",
- "enum": ["true", "false"]
+ "type": "boolean",
+ "description": "Whether the Duck.ai global setting is enabled"
+ },
+ {
+ "key": "input_screen_enabled",
+ "type": "boolean",
+ "description": "Whether the Search and Duck.ai input capability is enabled."
}
]
},
@@ -1234,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": {
@@ -1266,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/lint-baseline.xml b/app/lint-baseline.xml
index 4be940a493ba..a0fee809c334 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -888,7 +888,7 @@
errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
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/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
index 739b574afa09..00f7b717a67f 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
@@ -41,6 +41,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
+import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
@@ -61,7 +62,10 @@ import com.duckduckgo.app.browser.databinding.IncludeOmnibarToolbarMockupBinding
import com.duckduckgo.app.browser.databinding.IncludeOmnibarToolbarMockupBottomBinding
import com.duckduckgo.app.browser.defaultbrowsing.prompts.ui.DefaultBrowserBottomSheetDialog
import com.duckduckgo.app.browser.defaultbrowsing.prompts.ui.DefaultBrowserBottomSheetDialog.EventListener
+import com.duckduckgo.app.browser.mode.AppShortcutDuckAi
import com.duckduckgo.app.browser.mode.BrowserLaunchSource
+import com.duckduckgo.app.browser.mode.DuckAiPinShortcut
+import com.duckduckgo.app.browser.mode.SearchWidgetDuckAi
import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter
import com.duckduckgo.app.browser.omnibar.OmnibarType
import com.duckduckgo.app.browser.omnibar.applyAddressBarRebrandRadius
@@ -127,6 +131,7 @@ import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.downloads.api.DownloadsScreens.DownloadsScreenNoParams
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.viewmodel.DuckChatSharedViewModel
import com.duckduckgo.feedback.api.FeedbackScreenNoParams
import com.duckduckgo.navigation.api.GlobalActivityStarter
@@ -152,6 +157,12 @@ import logcat.logcat
import javax.inject.Inject
import com.duckduckgo.mobile.android.R as CommonR
+private fun BrowserLaunchSource.toDuckChatEntryPoint(): DuckChatEntryPoint? = when (this) {
+ AppShortcutDuckAi, DuckAiPinShortcut -> DuckChatEntryPoint.ICON_SHORTCUT
+ SearchWidgetDuckAi -> DuckChatEntryPoint.WIDGET_QUICK_ACTIONS
+ else -> null
+}
+
// open class so that we can test BrowserApplicationStateInfo
@HasMemberInjections
@InjectWith(ActivityScope::class)
@@ -813,6 +824,11 @@ open class BrowserActivity : DuckDuckGoActivity() {
if (intent.getBooleanExtra(OPEN_DUCK_CHAT, false)) {
val sourceTabId = intent.getStringExtra(SOURCE_TAB_ID_EXTRA)
+ intent.getStringExtra(DUCK_CHAT_ENTRY_POINT_EXTRA)?.let { source ->
+ runCatching { DuckChatEntryPoint.valueOf(source) }
+ .getOrNull()
+ ?.let { duckChat.reportDuckChatEntry(it, opensNewTab = true, hasPrompt = false) }
+ }
launchDuckAi(url = intent.getStringExtra(DUCK_CHAT_URL), sourceTabId = sourceTabId)
return
}
@@ -838,6 +854,13 @@ open class BrowserActivity : DuckDuckGoActivity() {
lifecycleScope.launch { viewModel.onOpenShortcut(sharedText) }
} else if (intent.getBooleanExtra(LAUNCH_FROM_FAVORITES_WIDGET, false)) {
logcat { "Favorite clicked from widget $sharedText" }
+ if (duckChat.isDuckChatUrl(sharedText.toUri())) {
+ duckChat.reportDuckChatEntry(
+ DuckChatEntryPoint.WIDGET_FAVORITE,
+ opensNewTab = true,
+ hasPrompt = hasAutoSubmittedPrompt(sharedText),
+ )
+ }
lifecycleScope.launch { viewModel.onOpenFavoriteFromWidget(query = sharedText) }
} else if (intent.getBooleanExtra(OPEN_IN_CURRENT_TAB_EXTRA, false)) {
logcat(WARN) { "open in current tab requested" }
@@ -854,6 +877,13 @@ open class BrowserActivity : DuckDuckGoActivity() {
} else {
val isExternal = intent.getBooleanExtra(LAUNCH_FROM_EXTERNAL_EXTRA, false)
val interstitialScreen = intent.getBooleanExtra(LAUNCH_FROM_INTERSTITIAL_EXTRA, false)
+ if (isExternal && duckChat.isDuckChatUrl(sharedText.toUri())) {
+ duckChat.reportDuckChatEntry(
+ DuckChatEntryPoint.DEEP_LINK_OTHER,
+ opensNewTab = true,
+ hasPrompt = hasAutoSubmittedPrompt(sharedText),
+ )
+ }
logcat(WARN) { "opening in new tab requested for $sharedText isExternal $isExternal interstitial $interstitialScreen" }
if (!interstitialScreen) {
logcat(WARN) { "not launching from interstitial screen" }
@@ -1066,6 +1096,11 @@ open class BrowserActivity : DuckDuckGoActivity() {
}
}
+ private fun hasAutoSubmittedPrompt(url: String): Boolean = runCatching {
+ val uri = url.toUri()
+ uri.getQueryParameter("prompt") == "1" && !uri.getQueryParameter("q").isNullOrBlank()
+ }.getOrDefault(false)
+
fun closeDuckChatFullScreen() {
isDuckChatVisible = false
currentTab?.closeCurrentTab()
@@ -1151,6 +1186,11 @@ open class BrowserActivity : DuckDuckGoActivity() {
finish()
}
is NewUserBrowserOnboardingViewModel.Command.OpenDuckAiOnboardingDemo -> {
+ duckChat.reportDuckChatEntry(
+ DuckChatEntryPoint.ONBOARDING,
+ opensNewTab = true,
+ hasPrompt = hasAutoSubmittedPrompt(command.url),
+ )
launchDuckAi(url = command.url)
}
}
@@ -1260,6 +1300,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
intent.putExtra(LAUNCH_FROM_INTERSTITIAL_EXTRA, interstitialScreen)
intent.putExtra(OPEN_EXISTING_TAB_ID_EXTRA, openExistingTabId)
intent.putExtra(OPEN_DUCK_CHAT, openDuckChat)
+ intent.putExtra(DUCK_CHAT_ENTRY_POINT_EXTRA, launchSource.toDuckChatEntryPoint()?.name)
intent.putExtra(CLOSE_DUCK_CHAT, closeDuckChat)
intent.putExtra(DUCK_CHAT_URL, duckChatUrl)
intent.putExtra(DUCK_CHAT_SESSION_ACTIVE, duckChatSessionActive)
@@ -1295,6 +1336,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
const val LAUNCH_SOURCE_PIXEL_VALUE = "LAUNCH_SOURCE_PIXEL_VALUE"
private const val OPEN_DUCK_CHAT = "OPEN_DUCK_CHAT_EXTRA"
+ private const val DUCK_CHAT_ENTRY_POINT_EXTRA = "DUCK_CHAT_ENTRY_POINT_EXTRA"
private const val CLOSE_DUCK_CHAT = "CLOSE_DUCK_CHAT_EXTRA"
private const val DUCK_CHAT_URL = "DUCK_CHAT_URL"
private const val DUCK_CHAT_SESSION_ACTIVE = "DUCK_CHAT_SESSION_ACTIVE"
diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
index f44fa58f5931..aba7cedb2994 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
@@ -338,6 +338,7 @@ import com.duckduckgo.downloads.api.FileDownloader
import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckchat.api.DuckChatContextual
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.DuckChatHistoryNoParams
import com.duckduckgo.duckchat.api.InputMode
import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.InteractionLock
@@ -824,7 +825,7 @@ class BrowserTabFragment :
viewModel.openNewDuckChat(omnibar.viewMode)
}
onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewVoiceChat)) {
- duckChat.openVoiceDuckChat()
+ duckChat.openVoiceDuckChat(DuckChatEntryPoint.VOICE)
}
onMenuItemClicked(contentView.findViewById(com.duckduckgo.duckchat.impl.R.id.chatMenuPopupNewTab)) {
browserActivity?.launchNewTab(browserMode = BrowserMode.REGULAR)
@@ -1080,7 +1081,7 @@ class BrowserTabFragment :
if (nativeInputManager.isNativeInputEnabled()) {
nativeInputManager.handleDuckAiVoiceResult(result.query)
} else {
- duckChat.openDuckChatWithAutoPrompt(result.query)
+ duckChat.openDuckChatWithAutoPrompt(result.query, DuckChatEntryPoint.VOICE)
}
}
}
@@ -1445,7 +1446,9 @@ class BrowserTabFragment :
)
},
onChatSuggestionSelected = { chatUrl -> viewModel.openDuckAiChatById(chatUrl) },
- onDuckAiQuerySubmitted = { query -> viewModel.openDuckAiQuery(query, autoPrompt = true) },
+ onDuckAiQuerySubmitted = { query, entryPoint ->
+ viewModel.openDuckAiQuery(query, autoPrompt = true, entryPoint = entryPoint)
+ },
onChatUrlSuggestionClicked = { suggestion -> viewModel.userSelectedAutocomplete(suggestion, firePixel = false) },
onChatHistoryShortcutClicked = {
pixel.fire(DuckChatPixelName.DUCK_CHAT_SETTINGS_SIDEBAR_TAPPED)
@@ -1870,7 +1873,7 @@ class BrowserTabFragment :
onMenuItemClicked(duckAiNewVoiceChatMenuItem) {
pixel.fire(DuckChatPixelName.DUCK_CHAT_VOICE_ENTRY_TAPPED_COUNT)
pixel.fire(DuckChatPixelName.DUCK_CHAT_VOICE_ENTRY_TAPPED_DAILY, type = Daily())
- duckChat.openVoiceDuckChat()
+ duckChat.openVoiceDuckChat(DuckChatEntryPoint.VOICE)
}
onMenuItemClicked(duckChatHistoryMenuItem) {
pixel.fire(DuckChatPixelName.DUCK_CHAT_SETTINGS_SIDEBAR_TAPPED)
diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
index 2a0e1f4bb848..baa15e817ff6 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
@@ -376,6 +376,9 @@ import com.duckduckgo.downloads.store.DownloadStatus
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckAiHostProvider
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
+import com.duckduckgo.duckchat.api.DuckChatInputModeState
+import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
import com.duckduckgo.duckchat.impl.contextual.PageContextJSHelper
import com.duckduckgo.duckchat.impl.contextual.RealPageContextJSHelper.Companion.PAGE_CONTEXT_FEATURE_NAME
import com.duckduckgo.duckchat.impl.helper.DuckChatJSHelper
@@ -540,6 +543,7 @@ class BrowserTabViewModel @Inject constructor(
private val duckChat: DuckChat,
private val duckAiHostProvider: DuckAiHostProvider,
private val duckAiFeatureState: DuckAiFeatureState,
+ private val duckChatInputModeState: DuckChatInputModeState,
private val duckPlayerJSHelper: DuckPlayerJSHelper,
private val refreshPixelSender: RefreshPixelSender,
private val privacyProtectionTogglePlugin: PluginPoint,
@@ -1532,7 +1536,9 @@ class BrowserTabViewModel @Inject constructor(
val verticalParameter = extractVerticalParameter(url)
var urlToNavigate = queryUrlConverter.convertQueryToUrl(trimmedInput, verticalParameter, queryOrigin)
- if (queryOrigin is QueryOrigin.FromUser && isTypedDuckAiUrl(urlToNavigate)) {
+ val isDuckAiDirectNavigation =
+ submissionSource == QuerySubmissionSource.USER && queryOrigin is QueryOrigin.FromUser && isTypedDuckAiUrl(urlToNavigate)
+ if (isDuckAiDirectNavigation) {
fireDuckAiDirectNavigationPixel()
}
@@ -1562,6 +1568,13 @@ class BrowserTabViewModel @Inject constructor(
}
else -> {
+ if (isDuckAiDirectNavigation) {
+ duckChat.reportDuckChatEntry(
+ DuckChatEntryPoint.DIRECT_URL,
+ opensNewTab = false,
+ hasPrompt = hasAutoSubmittedPrompt(urlToNavigate),
+ )
+ }
if (type is SpecialUrlDetector.UrlType.ExtractedAmpLink) {
logcat { "AMP link detection: Using extracted URL: ${type.extractedUrl}" }
urlToNavigate = type.extractedUrl
@@ -1645,8 +1658,17 @@ class BrowserTabViewModel @Inject constructor(
return host == duckAiHost || host == "www.$duckAiHost"
}
+ private fun hasAutoSubmittedPrompt(url: String): Boolean = runCatching {
+ val uri = url.toUri()
+ uri.getQueryParameter("prompt") == "1" && !uri.getQueryParameter(QUERY).isNullOrBlank()
+ }.getOrDefault(false)
+
private fun fireDuckAiDirectNavigationPixel() {
- val params = mapOf("duck_ai_enabled" to duckChat.isEnabled().toString())
+ val inputScreenEnabled = duckChatInputModeState.inputModeCapability.value == NativeInputState.InputMode.SEARCH_AND_DUCK_AI
+ val params = mapOf(
+ "duck_ai_enabled" to duckChat.isEnabled().toString(),
+ "input_screen_enabled" to inputScreenEnabled.toString(),
+ )
pixel.fire(AppPixelName.AI_CHAT_DUCK_AI_DIRECT_NAVIGATION_COUNT, parameters = params)
pixel.fire(AppPixelName.AI_CHAT_DUCK_AI_DIRECT_NAVIGATION_DAILY, parameters = params, type = Daily())
}
@@ -4036,9 +4058,9 @@ class BrowserTabViewModel @Inject constructor(
private fun openDuckChatForUrl(uri: Uri) {
val queryParameter = uri.getQueryParameter(QUERY)
if (queryParameter != null) {
- duckChat.openDuckChatWithPrefill(queryParameter)
+ duckChat.openDuckChatWithPrefill(queryParameter, DuckChatEntryPoint.DIRECT_URL)
} else {
- duckChat.openDuckChat()
+ duckChat.openDuckChat(DuckChatEntryPoint.DIRECT_URL)
}
}
@@ -5625,7 +5647,7 @@ class BrowserTabViewModel @Inject constructor(
}
private fun onUserTappedDuckAiPromptAutocomplete(prompt: String) {
- openDuckAiQuery(prompt, autoPrompt = true)
+ openDuckAiQuery(prompt, autoPrompt = true, entryPoint = DuckChatEntryPoint.SUGGESTION_ASK_AI)
viewModelScope.launch {
val params = duckChat.createWasUsedBeforePixelParams()
@@ -5639,12 +5661,22 @@ class BrowserTabViewModel @Inject constructor(
* selected. The query opens in a new tab so the current tab is preserved; on the NTP we
* reuse the empty tab instead of spawning another.
*/
- fun openDuckAiQuery(query: String, autoPrompt: Boolean) {
+ fun openDuckAiQuery(
+ query: String,
+ autoPrompt: Boolean,
+ entryPoint: DuckChatEntryPoint,
+ ) {
+ val duckAiUrl = duckChat.getDuckChatUrl(query, autoPrompt)
+ val hasPrompt = hasAutoSubmittedPrompt(duckAiUrl)
browserInteractionsPlugins.getPlugins().forEach { it.onInputSubmitted() }
- if (autoPrompt && query.isNotBlank()) {
+ if (hasPrompt) {
browserInteractionsPlugins.getPlugins().forEach { it.onAiPromptSubmitted() }
}
- navigateToDuckAi(duckChat.getDuckChatUrl(query, autoPrompt))
+ navigateToDuckAi(
+ url = duckAiUrl,
+ entryPoint = entryPoint,
+ hasPrompt = hasPrompt,
+ )
}
/**
@@ -5654,7 +5686,7 @@ class BrowserTabViewModel @Inject constructor(
*/
fun openDuckAiChatById(chatUrl: String) {
browserInteractionsPlugins.getPlugins().forEach { it.onChatSelected() }
- navigateToDuckAi(chatUrl)
+ navigateToDuckAi(chatUrl, DuckChatEntryPoint.CHAT_HISTORY_OPEN_CHAT, hasPrompt = false)
}
/**
@@ -5668,8 +5700,14 @@ class BrowserTabViewModel @Inject constructor(
browserInteractionsPlugins.getPlugins().forEach { it.onAiPromptSubmitted() }
}
- private fun navigateToDuckAi(url: String) {
- if (!currentBrowserViewState().browserShowing) {
+ private fun navigateToDuckAi(
+ url: String,
+ entryPoint: DuckChatEntryPoint,
+ hasPrompt: Boolean,
+ ) {
+ val opensNewTab = currentBrowserViewState().browserShowing
+ duckChat.reportDuckChatEntry(entryPoint, opensNewTab = opensNewTab, hasPrompt = hasPrompt)
+ if (!opensNewTab) {
submitQuery(url, QueryOrigin.FromUser, QuerySubmissionSource.INTERNAL_NAVIGATION)
} else {
command.value = OpenInNewTab(query = url, sourceTabId = tabId)
@@ -5685,6 +5723,12 @@ class BrowserTabViewModel @Inject constructor(
}
} else {
val url = duckChat.getDuckChatUrl("", false)
+ val entryPoint = if (viewMode == ViewMode.NewTab) {
+ DuckChatEntryPoint.BROWSING_MENU_NTP
+ } else {
+ DuckChatEntryPoint.BROWSING_MENU_WEBPAGE
+ }
+ duckChat.reportDuckChatEntry(entryPoint, opensNewTab = true, hasPrompt = false)
command.value = OpenInNewTab(url, tabId)
pixel.fire(DuckChatPixelName.DUCK_CHAT_SETTINGS_NEW_CHAT_TAB_TAPPED)
}
@@ -5776,6 +5820,11 @@ class BrowserTabViewModel @Inject constructor(
if (submittedAiPrompt) {
browserInteractionsPlugins.getPlugins().forEach { it.onAiPromptSubmitted() }
}
+ duckChat.reportDuckChatEntry(
+ DuckChatEntryPoint.ADDRESS_BAR_ICON,
+ opensNewTab = false,
+ hasPrompt = submittedAiPrompt,
+ )
submitQuery(url, QueryOrigin.FromUser, QuerySubmissionSource.INTERNAL_NAVIGATION)
} else {
// The typed-URL branch above: genuinely a URL submission, not a Duck.ai one.
diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt
index 4d36cc73f8a0..b5cb946bf525 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt
@@ -84,6 +84,7 @@ import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments
import com.duckduckgo.cookies.api.CookieManagerProvider
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.malicioussiteprotection.api.MaliciousSiteProtection.Feed
import com.duckduckgo.privacy.config.api.AmpLinks
import com.duckduckgo.subscriptions.api.Subscriptions
@@ -300,11 +301,12 @@ class BrowserWebViewClient @Inject constructor(
is SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink -> {
runCatching {
+ val entryPoint = duckChatEntryPointFor(webView.originalUrl)
val query = url.getQueryParameter(QUERY)
if (query != null) {
- duckChat.openDuckChatWithPrefill(query)
+ duckChat.openDuckChatWithPrefill(query, entryPoint)
} else {
- duckChat.openDuckChat()
+ duckChat.openDuckChat(entryPoint)
}
}.isSuccess
}
@@ -350,6 +352,18 @@ class BrowserWebViewClient @Inject constructor(
is SpecialUrlDetector.UrlType.SearchQuery -> false
is SpecialUrlDetector.UrlType.Web -> {
+ if (
+ isForMainFrame &&
+ hasGestureInNavigation &&
+ duckChat.isDuckChatUrl(url) &&
+ webView.originalUrl?.toUri()?.let(duckChat::isDuckChatUrl) != true
+ ) {
+ duckChat.reportDuckChatEntry(
+ entryPoint = duckChatEntryPointFor(webView.originalUrl),
+ opensNewTab = false,
+ hasPrompt = url.getQueryParameter("prompt") == "1" && !url.getQueryParameter(QUERY).isNullOrBlank(),
+ )
+ }
shouldOverrideWebRequest(url, webView, isForMainFrame)
}
@@ -429,6 +443,13 @@ class BrowserWebViewClient @Inject constructor(
}
}
+ private fun duckChatEntryPointFor(initiatingUrl: String?): DuckChatEntryPoint =
+ if (initiatingUrl?.let(duckDuckGoUrlDetector::isDuckDuckGoQueryUrl) == true) {
+ DuckChatEntryPoint.SERP
+ } else {
+ DuckChatEntryPoint.DIRECT_URL
+ }
+
private fun shouldOverrideWebRequest(
url: Uri,
webView: WebView,
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 d0267cdf3e58..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
@@ -57,6 +57,7 @@ import com.duckduckgo.common.utils.edgetoedge.EdgeToEdgeProvider
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.DuckChatInputModeState
import com.duckduckgo.duckchat.api.InputMode
import com.duckduckgo.duckchat.api.NativeInputEventListener
@@ -91,7 +92,7 @@ class NativeInputCallbacks(
filesJson: JSONArray?,
) -> Unit,
val onChatSuggestionSelected: (String) -> Unit,
- val onDuckAiQuerySubmitted: (query: String) -> Unit = {},
+ val onDuckAiQuerySubmitted: (query: String, entryPoint: DuckChatEntryPoint) -> Unit = { _, _ -> },
/** User picked a model in the native picker (→ submitChangeModelAction). */
val onChangeModelSubmitted: (modelId: String) -> Unit = {},
val onCustomizeResponsesClicked: () -> Unit = {},
@@ -355,12 +356,17 @@ class RealNativeInputManager @Inject constructor(
override fun handleDuckAiVoiceResult(query: String) {
val widget = widgetFrom(rootView)
if (widget != null) {
- if (!widget.isChatTabSelected()) {
- widget.selectChatTab()
+ widget.nextDuckAiEntryPoint = DuckChatEntryPoint.VOICE
+ try {
+ if (!widget.isChatTabSelected()) {
+ widget.selectChatTab()
+ }
+ widget.submitMessage(query)
+ } finally {
+ widget.nextDuckAiEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT
}
- widget.submitMessage(query)
} else {
- duckChat.openDuckChatWithAutoPrompt(query)
+ duckChat.openDuckChatWithAutoPrompt(query, DuckChatEntryPoint.VOICE)
}
}
@@ -743,7 +749,9 @@ class RealNativeInputManager @Inject constructor(
}
isExiting = false
nativeInputEventListener.onChatPromptSubmitted()
- callbacks.onDuckAiQuerySubmitted(query)
+ val entryPoint = widget.nextDuckAiEntryPoint
+ widget.nextDuckAiEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT
+ callbacks.onDuckAiQuerySubmitted(query, entryPoint)
}
},
)
@@ -911,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)
}
@@ -943,7 +952,7 @@ class RealNativeInputManager @Inject constructor(
}
widget.onVoiceChatClick = {
hideNativeInput(animate = false)
- duckChat.openVoiceDuckChat()
+ duckChat.openVoiceDuckChat(DuckChatEntryPoint.VOICE)
}
}
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/onboarding/ui/OnboardingActivity.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt
index a745fc59f722..6f351fd8e804 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt
@@ -37,6 +37,7 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.edgetoedge.EdgeToEdgeHandler
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -109,6 +110,7 @@ class OnboardingActivity : DuckDuckGoActivity() {
lifecycleScope.launch {
viewModel.onOnboardingDone(extendedOnboardingFlow = DUCK_AI_FOCUSED)
val duckChatUrl = duckChat.getDuckChatUrl(prompt, autoPrompt = true) + "&flow=mobile-app-onboarding"
+ duckChat.reportDuckChatEntry(DuckChatEntryPoint.ONBOARDING, opensNewTab = true, hasPrompt = prompt.isNotBlank())
startActivity(BrowserActivity.intent(this@OnboardingActivity, launchSource = Onboarding, duckChatUrl = duckChatUrl, openDuckChat = true))
finish()
}
diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt
index 22d98c06a563..074681d9db4e 100644
--- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt
+++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt
@@ -90,6 +90,7 @@ import com.duckduckgo.common.utils.extensions.showKeyboard
import com.duckduckgo.common.utils.text.TextChangedWatcher
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.savedsites.api.models.SavedSite
import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment
import com.duckduckgo.voice.api.VoiceSearchAvailability
@@ -436,7 +437,7 @@ class SystemSearchActivity : DuckDuckGoActivity() {
}
is VoiceSearchLauncher.VoiceRecognitionResult.DuckAiResult -> {
- viewModel.onDuckAiRequested(result.query)
+ viewModel.onDuckAiRequested(result.query, DuckChatEntryPoint.VOICE)
}
}
} else if (it is VoiceSearchLauncher.Event.VoiceSearchDisabled) {
@@ -451,7 +452,7 @@ class SystemSearchActivity : DuckDuckGoActivity() {
fun configureDuckAi() {
duckAi.setOnClickListener {
- viewModel.onDuckAiRequested(omnibarTextInput.text.toString())
+ viewModel.onDuckAiRequested(omnibarTextInput.text.toString(), DuckChatEntryPoint.SYSTEM_SEARCH)
}
}
@@ -572,7 +573,7 @@ class SystemSearchActivity : DuckDuckGoActivity() {
SystemSearchViewModel.Command.ExitSearch -> finish()
LaunchDuckAiVoiceChat -> {
- duckChat.openVoiceDuckChat()
+ duckChat.openVoiceDuckChat(DuckChatEntryPoint.DIGITAL_ASSISTANT)
finish()
}
}
diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt
index 773f5c689d55..627b3264db8d 100644
--- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt
+++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt
@@ -47,6 +47,7 @@ import com.duckduckgo.common.utils.SingleLiveEvent
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.history.api.NavigationHistory
import com.duckduckgo.savedsites.api.SavedSitesRepository
import com.duckduckgo.savedsites.api.models.SavedSite
@@ -323,8 +324,8 @@ class SystemSearchViewModel @Inject constructor(
voiceSearchState.tryEmit(Unit)
}
- fun onDuckAiRequested(query: String) {
- duckChat.openDuckChatWithAutoPrompt(query)
+ fun onDuckAiRequested(query: String, entryPoint: DuckChatEntryPoint) {
+ duckChat.openDuckChatWithAutoPrompt(query, entryPoint)
command.value = Command.ExitSearch
}
@@ -382,7 +383,7 @@ class SystemSearchViewModel @Inject constructor(
}
is AutoCompleteSuggestion.AutoCompleteDuckAIPrompt -> {
- onDuckAiRequested(suggestion.phrase)
+ onDuckAiRequested(suggestion.phrase, DuckChatEntryPoint.SUGGESTION_ASK_AI)
}
is AutoCompleteSuggestion.AutoCompleteDeviceAppSuggestion -> {
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/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt
index 5b38f809b552..ba0db1e93227 100644
--- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt
+++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt
@@ -70,6 +70,7 @@ import com.duckduckgo.common.utils.extensions.combine
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName
import com.duckduckgo.remote.messaging.api.RemoteMessageModel
import com.duckduckgo.savedsites.api.SavedSitesRepository
@@ -702,6 +703,7 @@ class TabSwitcherViewModel @Inject constructor(
pixel.fire(DuckChatPixelName.DUCK_CHAT_OPEN_TAB_SWITCHER_FAB, parameters = params)
val url = duckChat.getDuckChatUrl("", false)
+ duckChat.reportDuckChatEntry(DuckChatEntryPoint.TAB_SWITCHER, opensNewTab = true, hasPrompt = false)
tabRepository.add(url, true)
command.value = Command.Close
}
diff --git a/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt
index 9358a4b0b84c..329a5f1b4775 100644
--- a/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt
+++ b/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt
@@ -308,6 +308,9 @@ import com.duckduckgo.downloads.store.DownloadStatus
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckAiHostProvider
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
+import com.duckduckgo.duckchat.api.DuckChatInputModeState
+import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
import com.duckduckgo.duckchat.impl.contextual.PageContextJSHelper
import com.duckduckgo.duckchat.impl.contextual.RealPageContextJSHelper.Companion.PAGE_CONTEXT_FEATURE_NAME
import com.duckduckgo.duckchat.impl.helper.DuckChatJSHelper
@@ -533,8 +536,10 @@ class BrowserTabViewModelTest {
private val mockStandardizedLeadingIconToggle: StandardizedLeadingIconFeatureToggle = mock()
private val mockDuckAiFeatureState: DuckAiFeatureState = mock()
+ private val mockDuckChatInputModeState: DuckChatInputModeState = mock()
private val mockDuckAiFeatureStateInputScreenFlow = MutableStateFlow(false)
+ private val mockInputModeCapability = MutableStateFlow(NativeInputState.InputMode.SEARCH_ONLY)
private val mockDuckAiContextualModeFlow = MutableStateFlow(false)
@@ -705,6 +710,7 @@ class BrowserTabViewModelTest {
private val exampleUrl = "http://example.com"
private val shortExampleUrl = "example.com"
private val duckChatURL = "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5"
+ private val duckChatAutoPromptURL = "$duckChatURL&prompt=1"
private val selectedTab = TabEntity("TAB_ID", exampleUrl, position = 0, sourceTabId = "TAB_ID_SOURCE")
private val flowSelectedTab = MutableStateFlow(selectedTab)
@@ -838,6 +844,7 @@ class BrowserTabViewModelTest {
whenever(mockDuckAiFeatureState.showPopupMenuShortcut).thenReturn(MutableStateFlow(false))
whenever(mockDuckAiFeatureState.showInputScreen).thenReturn(mockDuckAiFeatureStateInputScreenFlow)
whenever(mockDuckAiFeatureState.showContextualMode).thenReturn(mockDuckAiContextualModeFlow)
+ whenever(mockDuckChatInputModeState.inputModeCapability).thenReturn(mockInputModeCapability)
whenever(mockVpnMenuStateProvider.getVpnMenuState()).thenReturn(flowOf(VpnMenuState.Hidden))
whenever(nonHttpAppLinkChecker.isPermitted(anyOrNull())).thenReturn(true)
runBlocking { whenever(mockAddressBarTrackersAnimationManager.isFeatureEnabled()).thenReturn(false) }
@@ -1004,6 +1011,7 @@ class BrowserTabViewModelTest {
duckChat = mockDuckChat,
duckAiHostProvider = mockDuckAiHostProvider,
duckAiFeatureState = mockDuckAiFeatureState,
+ duckChatInputModeState = mockDuckChatInputModeState,
duckPlayerJSHelper =
DuckPlayerJSHelper(
mockDuckPlayer,
@@ -6494,7 +6502,7 @@ class BrowserTabViewModelTest {
val handled = testee.handleDuckChatUrlInCustomTab("https://duck.ai/?q=hello".toUri())
assertTrue(handled)
- verify(mockDuckChat).openDuckChatWithPrefill("hello")
+ verify(mockDuckChat).openDuckChatWithPrefill("hello", DuckChatEntryPoint.DIRECT_URL)
assertTrue(captureCommands().allValues.contains(Command.FinishCustomTab))
}
@@ -6506,7 +6514,7 @@ class BrowserTabViewModelTest {
val handled = testee.handleDuckChatUrlInCustomTab("https://duck.ai/".toUri())
assertTrue(handled)
- verify(mockDuckChat).openDuckChat()
+ verify(mockDuckChat).openDuckChat(DuckChatEntryPoint.DIRECT_URL)
assertTrue(captureCommands().allValues.contains(Command.FinishCustomTab))
}
@@ -6518,8 +6526,8 @@ class BrowserTabViewModelTest {
val handled = testee.handleDuckChatUrlInCustomTab("https://duck.ai/?q=hello".toUri())
assertFalse(handled)
- verify(mockDuckChat, never()).openDuckChat()
- verify(mockDuckChat, never()).openDuckChatWithPrefill(any())
+ verify(mockDuckChat, never()).openDuckChat(any())
+ verify(mockDuckChat, never()).openDuckChatWithPrefill(any(), any())
}
@Test
@@ -6530,8 +6538,8 @@ class BrowserTabViewModelTest {
val handled = testee.handleDuckChatUrlInCustomTab("https://duck.ai/?q=hello".toUri())
assertFalse(handled)
- verify(mockDuckChat, never()).openDuckChat()
- verify(mockDuckChat, never()).openDuckChatWithPrefill(any())
+ verify(mockDuckChat, never()).openDuckChat(any())
+ verify(mockDuckChat, never()).openDuckChatWithPrefill(any(), any())
}
@Test
@@ -8274,13 +8282,58 @@ class BrowserTabViewModelTest {
verify(mockPixel).fire(
AppPixelName.AI_CHAT_DUCK_AI_DIRECT_NAVIGATION_COUNT,
- parameters = mapOf("duck_ai_enabled" to "true"),
+ parameters = mapOf("duck_ai_enabled" to "true", "input_screen_enabled" to "false"),
)
verify(mockPixel).fire(
AppPixelName.AI_CHAT_DUCK_AI_DIRECT_NAVIGATION_DAILY,
- parameters = mapOf("duck_ai_enabled" to "true"),
+ parameters = mapOf("duck_ai_enabled" to "true", "input_screen_enabled" to "false"),
type = Daily(),
)
+ verify(mockDuckChat).reportDuckChatEntry(DuckChatEntryPoint.DIRECT_URL, opensNewTab = false, hasPrompt = false)
+ }
+
+ @Test
+ fun whenTypedDuckAiUrlWithNonBlankQueryAndPromptSubmittedThenReportsDirectUrlEntryWithPrompt() {
+ val typedUrl = "https://duck.ai/chat?duckai=5&q=Hello&prompt=1"
+ whenever(mockOmnibarConverter.convertQueryToUrl(typedUrl, null, FromUser)).thenReturn(typedUrl)
+
+ testee.onUserSubmittedQuery(typedUrl, queryOrigin = FromUser)
+
+ verify(mockDuckChat).reportDuckChatEntry(DuckChatEntryPoint.DIRECT_URL, opensNewTab = false, hasPrompt = true)
+ }
+
+ @Test
+ fun whenTypedDuckAiUrlWithPromptButNoQuerySubmittedThenReportsDirectUrlEntryWithoutPrompt() {
+ val typedUrl = "https://duck.ai/chat?duckai=5&prompt=1"
+ whenever(mockOmnibarConverter.convertQueryToUrl(typedUrl, null, FromUser)).thenReturn(typedUrl)
+
+ testee.onUserSubmittedQuery(typedUrl, queryOrigin = FromUser)
+
+ verify(mockDuckChat).reportDuckChatEntry(DuckChatEntryPoint.DIRECT_URL, opensNewTab = false, hasPrompt = false)
+ }
+
+ @Test
+ fun whenTypedDuckAiUrlWithQueryButNoPromptParameterSubmittedThenReportsDirectUrlEntryWithoutPrompt() {
+ val typedUrl = "https://duck.ai/chat?duckai=5&q=Hello"
+ whenever(mockOmnibarConverter.convertQueryToUrl(typedUrl, null, FromUser)).thenReturn(typedUrl)
+
+ testee.onUserSubmittedQuery(typedUrl, queryOrigin = FromUser)
+
+ verify(mockDuckChat).reportDuckChatEntry(DuckChatEntryPoint.DIRECT_URL, opensNewTab = false, hasPrompt = false)
+ }
+
+ @Test
+ fun whenTypedDuckAiUrlSubmittedWithInputScreenEnabledThenDirectNavigationPixelCarriesCapability() {
+ mockInputModeCapability.value = NativeInputState.InputMode.SEARCH_AND_DUCK_AI
+ whenever(mockOmnibarConverter.convertQueryToUrl("duck.ai", null, FromUser)).thenReturn("https://duck.ai/")
+ whenever(mockDuckChat.isEnabled()).thenReturn(true)
+
+ testee.onUserSubmittedQuery("duck.ai", queryOrigin = FromUser)
+
+ verify(mockPixel).fire(
+ AppPixelName.AI_CHAT_DUCK_AI_DIRECT_NAVIGATION_COUNT,
+ parameters = mapOf("duck_ai_enabled" to "true", "input_screen_enabled" to "true"),
+ )
}
@Test
@@ -8292,7 +8345,7 @@ class BrowserTabViewModelTest {
verify(mockPixel).fire(
AppPixelName.AI_CHAT_DUCK_AI_DIRECT_NAVIGATION_COUNT,
- parameters = mapOf("duck_ai_enabled" to "true"),
+ parameters = mapOf("duck_ai_enabled" to "true", "input_screen_enabled" to "false"),
)
}
@@ -8305,11 +8358,11 @@ class BrowserTabViewModelTest {
verify(mockPixel).fire(
AppPixelName.AI_CHAT_DUCK_AI_DIRECT_NAVIGATION_COUNT,
- parameters = mapOf("duck_ai_enabled" to "false"),
+ parameters = mapOf("duck_ai_enabled" to "false", "input_screen_enabled" to "false"),
)
verify(mockPixel).fire(
AppPixelName.AI_CHAT_DUCK_AI_DIRECT_NAVIGATION_DAILY,
- parameters = mapOf("duck_ai_enabled" to "false"),
+ parameters = mapOf("duck_ai_enabled" to "false", "input_screen_enabled" to "false"),
type = Daily(),
)
}
@@ -8819,31 +8872,50 @@ class BrowserTabViewModelTest {
assertCommandIssued {
assertEquals(duckChatURL, query)
}
- verify(mockDuckChat, never()).openDuckChatWithAutoPrompt(any())
+ verify(mockDuckChat, never()).openDuckChatWithAutoPrompt(any(), any())
}
@Test
fun whenOpenDuckAiQueryOnBrowserTabThenOpensInNewTab() = runTest {
setBrowserShowing(true)
+ whenever(mockDuckChat.getDuckChatUrl(eq("hello"), eq(true), any())).thenReturn(duckChatAutoPromptURL)
- testee.openDuckAiQuery(query = "hello", autoPrompt = true)
+ testee.openDuckAiQuery(query = "hello", autoPrompt = true, entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT)
assertCommandIssued {
- assertEquals(duckChatURL, query)
+ assertEquals(duckChatAutoPromptURL, query)
}
verify(mockDuckChat).getDuckChatUrl(eq("hello"), eq(true), any())
- verify(mockDuckChat, never()).openDuckChatWithAutoPrompt(any())
+ verify(mockDuckChat).reportDuckChatEntry(DuckChatEntryPoint.ADDRESS_BAR_PROMPT, opensNewTab = true, hasPrompt = true)
+ verify(mockDuckChat, never()).openDuckChatWithAutoPrompt(any(), any())
}
@Test
fun whenOpenDuckAiQueryOnNtpThenStaysInTab() = runTest {
setBrowserShowing(false)
- whenever(mockOmnibarConverter.convertQueryToUrl(duckChatURL, null)).thenReturn(duckChatURL)
+ whenever(mockDuckChat.getDuckChatUrl(eq("hello"), eq(true), any())).thenReturn(duckChatAutoPromptURL)
+ whenever(mockOmnibarConverter.convertQueryToUrl(duckChatAutoPromptURL, null)).thenReturn(duckChatAutoPromptURL)
- testee.openDuckAiQuery(query = "hello", autoPrompt = true)
+ testee.openDuckAiQuery(query = "hello", autoPrompt = true, entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT)
assertCommandNotIssued()
verify(mockDuckChat).getDuckChatUrl(eq("hello"), eq(true), any())
+ verify(mockDuckChat).reportDuckChatEntry(DuckChatEntryPoint.ADDRESS_BAR_PROMPT, opensNewTab = false, hasPrompt = true)
+ }
+
+ @Test
+ fun whenOpenDuckAiQueryWithBangOnlyThenReportsNoPrompt() = runTest {
+ setBrowserShowing(true)
+ val bangOnlyUrl = "https://duck.ai/chat"
+ whenever(mockDuckChat.getDuckChatUrl(eq("!ai"), eq(true), any())).thenReturn(bangOnlyUrl)
+
+ testee.openDuckAiQuery(query = "!ai", autoPrompt = true, entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT)
+
+ verify(mockDuckChat).reportDuckChatEntry(
+ DuckChatEntryPoint.ADDRESS_BAR_PROMPT,
+ opensNewTab = true,
+ hasPrompt = false,
+ )
}
@Test
@@ -8856,6 +8928,7 @@ class BrowserTabViewModelTest {
assertCommandIssued {
assertEquals(chatUrl, query)
}
+ verify(mockDuckChat).reportDuckChatEntry(DuckChatEntryPoint.CHAT_HISTORY_OPEN_CHAT, opensNewTab = true, hasPrompt = false)
}
@Test
@@ -8873,9 +8946,10 @@ class BrowserTabViewModelTest {
fun whenOpenDuckAiQueryThenFiresOnInputSubmittedOnBrowserInteractionsPlugins() = runTest {
val plugin: BrowserInteractionsPlugin = mock()
whenever(mockBrowserInteractionsPlugins.getPlugins()).thenReturn(listOf(plugin))
- whenever(mockOmnibarConverter.convertQueryToUrl(duckChatURL, null)).thenReturn(duckChatURL)
+ whenever(mockDuckChat.getDuckChatUrl(eq("hello"), eq(true), any())).thenReturn(duckChatAutoPromptURL)
+ whenever(mockOmnibarConverter.convertQueryToUrl(duckChatAutoPromptURL, null)).thenReturn(duckChatAutoPromptURL)
- testee.openDuckAiQuery(query = "hello", autoPrompt = true)
+ testee.openDuckAiQuery(query = "hello", autoPrompt = true, entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT)
// Preserve the pre-return-session behavior: one callback is explicit and one comes from
// reusing the NTP tab. The new AI classifier must still fire exactly once without a URL.
@@ -8891,7 +8965,7 @@ class BrowserTabViewModelTest {
whenever(mockBrowserInteractionsPlugins.getPlugins()).thenReturn(listOf(plugin))
setBrowserShowing(true)
- testee.openDuckAiQuery(query = "", autoPrompt = false)
+ testee.openDuckAiQuery(query = "", autoPrompt = false, entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT)
verify(plugin).onInputSubmitted()
verify(plugin, never()).onAiPromptSubmitted()
@@ -8903,7 +8977,7 @@ class BrowserTabViewModelTest {
whenever(mockBrowserInteractionsPlugins.getPlugins()).thenReturn(listOf(plugin))
setBrowserShowing(true)
- testee.openDuckAiQuery(query = "prefill", autoPrompt = false)
+ testee.openDuckAiQuery(query = "prefill", autoPrompt = false, entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT)
verify(plugin).onInputSubmitted()
verify(plugin, never()).onAiPromptSubmitted()
@@ -8914,8 +8988,9 @@ class BrowserTabViewModelTest {
val plugin: BrowserInteractionsPlugin = mock()
whenever(mockBrowserInteractionsPlugins.getPlugins()).thenReturn(listOf(plugin))
setBrowserShowing(true)
+ whenever(mockDuckChat.getDuckChatUrl(eq("hello"), eq(true), any())).thenReturn(duckChatAutoPromptURL)
- testee.openDuckAiQuery(query = "hello", autoPrompt = true)
+ testee.openDuckAiQuery(query = "hello", autoPrompt = true, entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT)
verify(plugin).onInputSubmitted()
verify(plugin).onAiPromptSubmitted()
@@ -9101,7 +9176,7 @@ class BrowserTabViewModelTest {
"https://duckduckgo.com/?q=example&ia=chat&duckai=5",
)
testee.onUserSubmittedQuery("https://duckduckgo.com/?q=example&ia=chat&duckai=5")
- mockDuckChat.openDuckChatWithPrefill("example")
+ mockDuckChat.openDuckChatWithPrefill("example", DuckChatEntryPoint.DIRECT_URL)
}
@Test
@@ -9109,7 +9184,7 @@ class BrowserTabViewModelTest {
whenever(mockSpecialUrlDetector.determineType(anyString())).thenReturn(SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink)
whenever(mockOmnibarConverter.convertQueryToUrl("https://duckduckgo.com/?ia=chat", null)).thenReturn("https://duckduckgo.com/?ia=chat")
testee.onUserSubmittedQuery("https://duckduckgo.com/?ia=chat")
- mockDuckChat.openDuckChat()
+ mockDuckChat.openDuckChat(DuckChatEntryPoint.DIRECT_URL)
}
@Test
@@ -10977,7 +11052,7 @@ class BrowserTabViewModelTest {
val command = commandCaptor.lastValue as Command.OpenInNewTab
assertTrue(command.query == duckChatURL)
- verify(mockDuckChat, never()).openDuckChat()
+ verify(mockDuckChat, never()).openDuckChat(any())
verify(mockPixel).fire(DuckChatPixelName.DUCK_CHAT_SETTINGS_NEW_CHAT_TAB_TAPPED)
}
@@ -11323,7 +11398,7 @@ class BrowserTabViewModelTest {
val command = commandCaptor.lastValue as Navigate
assertEquals(duckAIUrl, command.url)
- verify(mockDuckChat, never()).openDuckChat()
+ verify(mockDuckChat, never()).openDuckChat(any())
}
@Test
diff --git a/app/src/test/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/test/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt
index 14616211746e..b96edd0ba5c0 100644
--- a/app/src/test/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt
+++ b/app/src/test/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt
@@ -85,6 +85,7 @@ import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments
import com.duckduckgo.cookies.api.CookieManagerProvider
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.privacy.config.api.AmpLinks
import com.duckduckgo.subscriptions.api.Subscriptions
@@ -574,7 +575,7 @@ class BrowserWebViewClientTest {
whenever(webResourceRequest.url).thenReturn("https://duckduckgo.com/?q=example&ia=chat&duckai=5".toUri())
assertTrue(testee.shouldOverrideUrlLoading(webView, webResourceRequest))
- verify(mockDuckChat).openDuckChatWithPrefill("example")
+ verify(mockDuckChat).openDuckChatWithPrefill("example", DuckChatEntryPoint.DIRECT_URL)
}
@Test
@@ -583,7 +584,38 @@ class BrowserWebViewClientTest {
whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any())).thenReturn(urlType)
whenever(webResourceRequest.url).thenReturn("https://duckduckgo.com/?ia=chat".toUri())
assertTrue(testee.shouldOverrideUrlLoading(webView, webResourceRequest))
- verify(mockDuckChat).openDuckChat()
+ verify(mockDuckChat).openDuckChat(DuckChatEntryPoint.DIRECT_URL)
+ }
+
+ @Test
+ fun whenDuckChatLinkIsLaunchedFromSerpThenSourceIsSerp() {
+ val serpWebView: WebView = mock()
+ whenever(serpWebView.originalUrl).thenReturn("https://duckduckgo.com/?q=privacy")
+ whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true)
+ whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any()))
+ .thenReturn(SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink)
+ whenever(webResourceRequest.url).thenReturn("https://duck.ai/".toUri())
+
+ testee.shouldOverrideUrlLoading(serpWebView, webResourceRequest)
+
+ verify(mockDuckChat).openDuckChat(DuckChatEntryPoint.SERP)
+ }
+
+ @Test
+ fun whenInPageDuckChatLinkNavigatesInPlaceThenDirectUrlEntryIsReported() {
+ val pageWebView: WebView = mock()
+ whenever(pageWebView.originalUrl).thenReturn("https://example.com/page")
+ whenever(pageWebView.url).thenReturn("https://example.com/page")
+ whenever(webResourceRequest.url).thenReturn("https://duck.ai/?q=prefill".toUri())
+ whenever(webResourceRequest.isForMainFrame).thenReturn(true)
+ whenever(webResourceRequest.hasGesture()).thenReturn(true)
+ whenever(mockDuckChat.isDuckChatUrl("https://duck.ai/?q=prefill".toUri())).thenReturn(true)
+ whenever(specialUrlDetector.determineType(initiatingUrl = any(), uri = any()))
+ .thenReturn(SpecialUrlDetector.UrlType.Web("https://duck.ai/?q=prefill"))
+
+ testee.shouldOverrideUrlLoading(pageWebView, webResourceRequest)
+
+ verify(mockDuckChat).reportDuckChatEntry(DuckChatEntryPoint.DIRECT_URL, opensNewTab = false, hasPrompt = false)
}
@Test
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 9e5b09d37e99..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
@@ -39,6 +39,7 @@ import com.duckduckgo.common.utils.edgetoedge.EdgeToEdgeHandler
import com.duckduckgo.common.utils.edgetoedge.EdgeToEdgeProvider
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.DuckChatInputModeState
import com.duckduckgo.duckchat.api.NativeInputEventListener
import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
@@ -348,6 +349,90 @@ class RealNativeInputManagerTest {
}
}
+ @Test
+ fun whenBlankDuckAiVoiceResultFollowedByTypedSubmissionThenTypedSubmissionUsesAddressBarPromptEntryPoint() {
+ val entryPoints = mutableListOf()
+ val widget = showVoiceTestWidget { _, entryPoint -> entryPoints += entryPoint }
+
+ testee.handleDuckAiVoiceResult("")
+ widget.submitMessage("typed query")
+
+ assertEquals(listOf(DuckChatEntryPoint.ADDRESS_BAR_PROMPT), entryPoints)
+ }
+
+ @Test
+ fun whenWhitespaceDuckAiVoiceResultFollowedByTypedSubmissionThenTypedSubmissionUsesAddressBarPromptEntryPoint() {
+ val entryPoints = mutableListOf()
+ val widget = showVoiceTestWidget { _, entryPoint -> entryPoints += entryPoint }
+
+ testee.handleDuckAiVoiceResult(" ")
+ widget.submitMessage("typed query")
+
+ assertEquals(listOf(DuckChatEntryPoint.ADDRESS_BAR_PROMPT), entryPoints)
+ }
+
+ @Test
+ fun whenNonBlankDuckAiVoiceResultThenSubmissionUsesVoiceEntryPoint() {
+ val entryPoints = mutableListOf()
+ showVoiceTestWidget { _, entryPoint -> entryPoints += entryPoint }
+
+ testee.handleDuckAiVoiceResult("voice query")
+
+ assertEquals(listOf(DuckChatEntryPoint.VOICE), entryPoints)
+ }
+
+ @Test
+ fun whenTypedSubmissionFollowsSuccessfulDuckAiVoiceSubmissionThenTypedSubmissionUsesAddressBarPromptEntryPoint() {
+ val entryPoints = mutableListOf()
+ val onSubmitted = { _: String, entryPoint: DuckChatEntryPoint -> entryPoints += entryPoint }
+ showVoiceTestWidget(onSubmitted)
+ testee.handleDuckAiVoiceResult("voice query")
+
+ val widget = showVoiceTestWidget(onSubmitted)
+ widget.selectChatTab()
+ widget.submitMessage("typed query")
+
+ assertEquals(
+ listOf(DuckChatEntryPoint.VOICE, DuckChatEntryPoint.ADDRESS_BAR_PROMPT),
+ entryPoints,
+ )
+ }
+
+ private fun showVoiceTestWidget(onDuckAiQuerySubmitted: (String, DuckChatEntryPoint) -> Unit): TestNativeInputWidget {
+ whenever(duckChat.observeNativeInputFieldUserSettingEnabled()).thenReturn(MutableStateFlow(true))
+ whenever(duckChat.observeNativeChatInputEnabled()).thenReturn(MutableStateFlow(true))
+ whenever(duckAiFeatureState.showVoiceSearchToggle).thenReturn(MutableStateFlow(false))
+ whenever(duckAiFeatureState.showVoiceChatEntry).thenReturn(MutableStateFlow(false))
+ whenever(omnibar.viewMode).thenReturn(Omnibar.ViewMode.NewTab)
+ whenever(omnibar.getText()).thenReturn("")
+ testee.init(omnibar, rootView, lifecycleOwner)
+ if (rootView.findViewById(R.id.includeNewBrowserTab) == null) {
+ rootView.addView(FrameLayout(context).apply { id = R.id.includeNewBrowserTab })
+ }
+
+ val widget = TestNativeInputWidget(context).apply { id = R.id.inputModeWidget }
+ val widgetView = FrameLayout(context).apply { addView(widget) }
+ val layoutInflater: LayoutInflater = mock()
+ whenever(layoutInflater.inflate(any(), any(), any())).thenReturn(widgetView)
+ testee.showNativeInput(
+ tabId = "tab",
+ layoutInflater = layoutInflater,
+ lifecycleOwner = lifecycleOwner,
+ tabs = mock>>(),
+ currentTabUrl = emptyFlow(),
+ callbacks = NativeInputCallbacks(
+ onSearchTextChanged = {},
+ onSearchSubmitted = {},
+ onDuckAiChatSubmitted = { _, _, _, _, _, _ -> },
+ onChatSuggestionSelected = {},
+ onDuckAiQuerySubmitted = onDuckAiQuerySubmitted,
+ onClearAutocomplete = {},
+ onStopTapped = {},
+ ),
+ )
+ return widget
+ }
+
private fun showNativeInput() {
testee.showNativeInput(
tabId = "tab",
@@ -371,6 +456,38 @@ class RealNativeInputManagerTest {
override val lifecycle: Lifecycle get() = registry
}
+ private class TestNativeInputWidget(
+ context: Context,
+ delegate: NativeInputWidget = mock(),
+ ) : View(context), NativeInputWidget by delegate {
+ private var chatTabSelected = false
+ private var onChatSubmitted: ((String) -> Unit)? = null
+
+ override var text: String = ""
+ override var nextDuckAiEntryPoint: DuckChatEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT
+
+ override fun bindInputEvents(
+ onSearchTextChanged: (String) -> Unit,
+ onSearchSubmitted: (String) -> Unit,
+ onChatSubmitted: (String) -> Unit,
+ onInputTextEmptyChanged: (isEmpty: Boolean) -> Unit,
+ ) {
+ this.onChatSubmitted = onChatSubmitted
+ }
+
+ override fun selectChatTab() {
+ chatTabSelected = true
+ }
+
+ override fun isChatTabSelected(): Boolean = chatTabSelected
+
+ override fun submitMessage(message: String?) {
+ message?.trim()?.takeIf { it.isNotEmpty() }?.let { onChatSubmitted?.invoke(it) }
+ }
+
+ override fun asView(): View = this
+ }
+
@Test
fun whenChatHeaderUpgradeTappedByFreeUserThenPixelFiredWithFreeTierAndOrigin() {
testee.fireChatHeaderUpgradeTapped(DuckAiTier.Free)
diff --git a/app/src/test/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt
index 7e76a4a0d711..e909cfb52f6a 100644
--- a/app/src/test/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt
+++ b/app/src/test/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt
@@ -48,6 +48,7 @@ import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.common.test.InstantSchedulersRule
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
import com.duckduckgo.feature.toggles.api.Toggle.State
import com.duckduckgo.history.api.NavigationHistory
@@ -691,8 +692,8 @@ class SystemSearchViewModelTest {
@Test
fun onDuckAiTappedThenDuckChatOpenedWithQuery() {
val query = "What is DuckDuckGo?"
- testee.onDuckAiRequested(query)
- verify(mockDuckChat).openDuckChatWithAutoPrompt(query)
+ testee.onDuckAiRequested(query, DuckChatEntryPoint.SYSTEM_SEARCH)
+ verify(mockDuckChat).openDuckChatWithAutoPrompt(query, DuckChatEntryPoint.SYSTEM_SEARCH)
}
@Test
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-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt
index 030f4fa759c8..9e2b39df6c36 100644
--- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt
+++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt
@@ -32,19 +32,32 @@ interface DuckChat {
fun isEnabled(): Boolean
/**
- * Opens the DuckChat WebView with optional pre-filled [String] query.
+ * Opens Duck.ai from [entryPoint].
*/
- fun openDuckChat()
+ fun openDuckChat(entryPoint: DuckChatEntryPoint)
/**
- * Auto-prompts the DuckChat WebView with the provided [String] query.
+ * Opens Duck.ai from [entryPoint] and automatically submits [query].
*/
- fun openDuckChatWithAutoPrompt(query: String)
+ fun openDuckChatWithAutoPrompt(query: String, entryPoint: DuckChatEntryPoint)
/**
- * Opens Duck Chat with a prefilled [String] query.
+ * Opens Duck.ai from [entryPoint] with [query] prefilled but not submitted.
*/
- fun openDuckChatWithPrefill(query: String)
+ fun openDuckChatWithPrefill(query: String, entryPoint: DuckChatEntryPoint)
+
+ /**
+ * Records a Duck.ai entry performed through navigation that bypasses the open methods.
+ *
+ * @param entryPoint the surface that initiated the entry.
+ * @param opensNewTab whether the entry opens a new browser tab.
+ * @param hasPrompt whether a non-blank prompt is automatically submitted on entry.
+ */
+ fun reportDuckChatEntry(
+ entryPoint: DuckChatEntryPoint,
+ opensNewTab: Boolean,
+ hasPrompt: Boolean,
+ )
/**
* Returns the Duck Chat URL to be used
@@ -138,7 +151,7 @@ interface DuckChat {
/**
* Opens Duck.ai directly in voice mode (duck.ai/?mode=voice-mode).
*/
- fun openVoiceDuckChat()
+ fun openVoiceDuckChat(entryPoint: DuckChatEntryPoint)
/**
* Returns `true` if a voice session is currently active on the tab with the given [tabId].
diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatEntryPoint.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatEntryPoint.kt
new file mode 100644
index 000000000000..24673c701fe8
--- /dev/null
+++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChatEntryPoint.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.api
+
+/** The user-visible surface that initiated an entry into Duck.ai. */
+enum class DuckChatEntryPoint {
+ 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,
+}
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 23da5a114e17..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
@@ -26,8 +26,8 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.di.IsMainProcess
-import com.duckduckgo.app.statistics.pixels.Pixel
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
@@ -38,12 +38,15 @@ import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckAiHostProvider
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.DuckChatInputModeState
import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams
import com.duckduckgo.duckchat.api.InputMode
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
@@ -56,6 +59,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
+import dagger.Lazy
import dagger.SingleInstanceIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -179,7 +183,7 @@ interface DuckChatInternal : DuckChat {
/**
* Opens DuckChat with a new session.
*/
- fun openNewDuckChatSession()
+ fun openNewDuckChatSession(entryPoint: DuckChatEntryPoint)
/** Single source of truth for the Duck.ai chat URL shape. */
fun buildChatUrl(chatId: String): String
@@ -459,7 +463,7 @@ class RealDuckChat @Inject constructor(
private val context: Context,
@IsMainProcess private val isMainProcess: Boolean,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
- private val pixel: Pixel,
+ private val duckChatPixels: Lazy,
private val imageUploadFeature: AIChatImageUploadFeature,
private val browserNav: BrowserNav,
private val deviceSyncState: DeviceSyncState,
@@ -468,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,
@@ -717,13 +722,15 @@ class RealDuckChat @Inject constructor(
override fun keepSessionIntervalInMinutes() = keepSessionAliveInMinutes
- override fun openDuckChat() {
+ override fun openDuckChat(entryPoint: DuckChatEntryPoint) {
logcat { "Duck.ai: openDuckChat" }
+ reportDuckChatEntry(entryPoint, opensNewTab = true, hasPrompt = false)
openDuckChat(emptyMap())
}
- override fun openDuckChatWithAutoPrompt(query: String) {
+ override fun openDuckChatWithAutoPrompt(query: String, entryPoint: DuckChatEntryPoint) {
logcat { "Duck.ai: openDuckChatWithAutoPrompt query $query" }
+ reportDuckChatEntry(entryPoint, opensNewTab = true, hasPrompt = stripBang(query).isNotEmpty())
val parameters = addChatParameters(query, autoPrompt = true, sidebar = false)
openDuckChat(parameters, forceNewSession = true)
}
@@ -732,8 +739,9 @@ class RealDuckChat @Inject constructor(
addressBarPickerAttributionRepository.onPickerDuckAiSelected()
}
- override fun openDuckChatWithPrefill(query: String) {
+ override fun openDuckChatWithPrefill(query: String, entryPoint: DuckChatEntryPoint) {
logcat { "Duck.ai: openDuckChatWithPrefill query $query" }
+ reportDuckChatEntry(entryPoint, opensNewTab = true, hasPrompt = false)
val parameters = addChatParameters(query, autoPrompt = false, sidebar = false)
openDuckChat(parameters, forceNewSession = true)
}
@@ -777,16 +785,35 @@ class RealDuckChat @Inject constructor(
return query.replace(bangPattern, "").trim()
}
- override fun openVoiceDuckChat() {
+ override fun openVoiceDuckChat(entryPoint: DuckChatEntryPoint) {
logcat { "Duck.ai: openVoiceDuckChat" }
+ reportDuckChatEntry(entryPoint, opensNewTab = true, hasPrompt = false)
val parameters = mapOf(MODE_QUERY_NAME to VOICE_MODE_QUERY_VALUE)
openDuckChat(parameters, forceNewSession = true)
}
- override fun openNewDuckChatSession() {
+ override fun openNewDuckChatSession(entryPoint: DuckChatEntryPoint) {
+ reportDuckChatEntry(entryPoint, opensNewTab = true, hasPrompt = false)
openDuckChat(emptyMap(), forceNewSession = true)
}
+ override fun reportDuckChatEntry(
+ entryPoint: DuckChatEntryPoint,
+ 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,
+ hasPrompt = hasPrompt,
+ duckAiEnabled = isEnabled(),
+ inputScreenEnabled = inputModeCapability.value == NativeInputState.InputMode.SEARCH_AND_DUCK_AI,
+ )
+ }
+
private fun openDuckChat(
parameters: Map,
forceNewSession: Boolean = false,
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt
index c2d1d4b8057a..a721b5d5a04a 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualFragment.kt
@@ -86,6 +86,7 @@ import com.duckduckgo.downloads.api.DownloadStateListener
import com.duckduckgo.downloads.api.DownloadsFileActions
import com.duckduckgo.downloads.api.FileDownloader
import com.duckduckgo.duckchat.api.DuckChatContextual
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.DuckChatHistoryNoParams
import com.duckduckgo.duckchat.api.viewmodel.DuckChatSharedViewModel
import com.duckduckgo.duckchat.impl.DuckChatInternal
@@ -528,7 +529,7 @@ class DuckChatContextualFragment :
onPageContextRemoved = { viewModel.removePageContext() },
onVoiceChatRequested = {
viewModel.onContextualClose()
- duckChat.openVoiceDuckChat()
+ duckChat.openVoiceDuckChat(DuckChatEntryPoint.VOICE)
},
onVoiceSearchRequested = {
activity?.hideKeyboard()
@@ -721,6 +722,10 @@ class DuckChatContextualFragment :
}
}
+ private fun openDuckAiWithPrompt(query: String) {
+ duckChat.openDuckChatWithAutoPrompt(query, DuckChatEntryPoint.CONTEXTUAL_CHAT)
+ }
+
private fun observeViewModel() {
viewModel.commands
.onEach { command ->
@@ -781,7 +786,7 @@ class DuckChatContextualFragment :
is DuckChatContextualViewModel.Command.OpenDuckAiWithPrompt -> {
viewModel.onContextualClose()
- duckChat.openDuckChatWithAutoPrompt(command.query)
+ openDuckAiWithPrompt(command.query)
}
is DuckChatContextualViewModel.Command.FocusInput -> {
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModel.kt
index 0b8909985627..e22691502762 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModel.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModel.kt
@@ -27,6 +27,7 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.toChatIdOrNull
import com.duckduckgo.duckchat.impl.DuckChatInternal
import com.duckduckgo.duckchat.impl.R
@@ -748,6 +749,7 @@ class DuckChatContextualViewModel @Inject constructor(
fun onFullModeRequested() {
logcat { "Duck.ai: request fullmode url $fullModeUrl" }
val currentState = _viewState.value
+ val hasPrompt = currentState.sheetMode != SheetMode.INPUT
val chatUrl = if (currentState.sheetMode == SheetMode.INPUT) {
duckChat.getDuckChatUrl("", false, sidebar = false)
} else {
@@ -758,6 +760,7 @@ class DuckChatContextualViewModel @Inject constructor(
viewModelScope.launch {
commandChannel.trySend(Command.OpenFullscreenMode(chatUrl))
}
+ duckChatInternal.reportDuckChatEntry(DuckChatEntryPoint.CONTEXTUAL_CHAT, opensNewTab = true, hasPrompt = hasPrompt)
duckChatPixels.reportContextualSheetExpanded()
}
@@ -903,6 +906,7 @@ class DuckChatContextualViewModel @Inject constructor(
duckChatPixels.reportContextualRecentChatSelected()
val url = duckChatInternal.buildChatUrl(chatId)
val sourceTabId = _viewState.value.tabId
+ duckChatInternal.reportDuckChatEntry(DuckChatEntryPoint.CHAT_HISTORY_OPEN_CHAT, opensNewTab = true, hasPrompt = false)
commandChannel.trySend(Command.OpenChatUrl(url = url, sourceTabId = sourceTabId))
}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt
index 215138107c8a..d0e66b21c401 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/contextual/RealDuckChatContextual.kt
@@ -26,6 +26,7 @@ import com.duckduckgo.app.tabs.BrowserNav
import com.duckduckgo.common.ui.menu.PopupMenu
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.api.DuckChatContextual
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.impl.DuckChatInternal
import com.duckduckgo.duckchat.impl.R
import com.duckduckgo.duckchat.impl.store.DuckChatContextualDataStore
@@ -93,6 +94,7 @@ class RealDuckChatContextual @Inject constructor(
private fun openNewChatTab(activity: Activity, sourceTabId: String) {
val url = duckChatInternal.getDuckChatUrl(query = "", autoPrompt = false)
+ duckChatInternal.reportDuckChatEntry(DuckChatEntryPoint.CONTEXTUAL_CHAT, opensNewTab = true, hasPrompt = false)
browserNav.openInNewTab(activity, url, sourceTabId).also { activity.startActivity(it) }
}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt
index 8d336d8ae8e6..9d5ba5c6ffb1 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt
@@ -27,6 +27,7 @@ import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.di.scopes.AppScope
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStateProvider
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStatePublisher
import com.duckduckgo.duckchat.impl.ChatState
@@ -176,7 +177,11 @@ class RealDuckChatJSHelper @Inject constructor(
METHOD_OPEN_AI_CHAT -> {
val payload = extractPayload(data)
dataStore.updateUserPreferences(payload)
- duckChat.openNewDuckChatSession()
+ val entryPoint = when {
+ mode == Mode.CONTEXTUAL -> DuckChatEntryPoint.CONTEXTUAL_CHAT
+ else -> DuckChatEntryPoint.DIRECT_URL
+ }
+ duckChat.openNewDuckChatSession(entryPoint)
null
}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt
index 7b81cc8c8183..dfce85a73f31 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt
@@ -28,6 +28,7 @@ import com.duckduckgo.dataclearing.api.plugin.ClearableData
import com.duckduckgo.dataclearing.api.plugin.DataClearingTrigger
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.duckchat.api.DuckAiFeatureState
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.impl.DuckChatInternal
import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.Loaded
import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.Mode
@@ -101,6 +102,7 @@ class ChatHistoryViewModel @Inject constructor(
// Open the chat as a new tab anchored to the tab the user was on, so closing it returns there.
viewModelScope.launch {
val sourceTabId = tabRepository.getSelectedTab()?.tabId
+ duckChat.reportDuckChatEntry(DuckChatEntryPoint.CHAT_HISTORY_OPEN_CHAT, opensNewTab = true, hasPrompt = false)
navigationChannel.trySend(NavigationEvent.OpenChat(url = duckChat.buildChatUrl(chatId), sourceTabId = sourceTabId))
}
}
@@ -133,13 +135,13 @@ class ChatHistoryViewModel @Inject constructor(
DuckChatPixelName.DUCK_CHAT_HISTORY_EMPTY_CTA_TAPPED_COUNT,
DuckChatPixelName.DUCK_CHAT_HISTORY_EMPTY_CTA_TAPPED_DAILY,
)
- duckChat.openDuckChat()
+ duckChat.openDuckChat(DuckChatEntryPoint.CHAT_HISTORY_NEW_CHAT)
}
/** Toolbar "New chat" action. Kept separate from [onOpenDuckAiClicked] so the two surfaces stay independently instrumentable. */
fun onNewChatRequested() {
pixel.fireCountAndDaily(DuckChatPixelName.DUCK_CHAT_HISTORY_NEW_CHAT_TAPPED_COUNT, DuckChatPixelName.DUCK_CHAT_HISTORY_NEW_CHAT_TAPPED_DAILY)
- duckChat.openDuckChat()
+ duckChat.openDuckChat(DuckChatEntryPoint.CHAT_HISTORY_NEW_CHAT)
}
fun onFireIconClicked() {
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/nativeinput/MetricsNativeInputEventListener.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/nativeinput/MetricsNativeInputEventListener.kt
index e33193af3f0f..da5cebf2f655 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/nativeinput/MetricsNativeInputEventListener.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/nativeinput/MetricsNativeInputEventListener.kt
@@ -17,7 +17,9 @@
package com.duckduckgo.duckchat.impl.metric.nativeinput
import com.duckduckgo.di.scopes.AppScope
+import com.duckduckgo.duckchat.api.DuckChatInputModeState
import com.duckduckgo.duckchat.api.NativeInputEventListener
+import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
import com.duckduckgo.duckchat.impl.metric.nativeinput.discovery.InputScreenDiscoveryFunnel
import com.duckduckgo.duckchat.impl.metric.nativeinput.usage.InputScreenSessionUsageMetric
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels
@@ -27,6 +29,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class MetricsNativeInputEventListener @Inject constructor(
private val duckChatPixels: DuckChatPixels,
+ private val duckChatInputModeState: DuckChatInputModeState,
private val sessionUsageMetric: InputScreenSessionUsageMetric,
private val discoveryFunnel: InputScreenDiscoveryFunnel,
) : NativeInputEventListener {
@@ -34,7 +37,8 @@ class MetricsNativeInputEventListener @Inject constructor(
override fun onNativeInputShown(landscape: Boolean) {
discoveryFunnel.onNativeInputActive()
discoveryFunnel.onInputScreenOpened()
- duckChatPixels.fireOmnibarShown()
+ val toggleVisible = duckChatInputModeState.inputModeCapability.value == NativeInputState.InputMode.SEARCH_AND_DUCK_AI
+ duckChatPixels.fireOmnibarShown(toggleVisible)
duckChatPixels.fireOmnibarTextAreaFocused(landscape = landscape)
}
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 4dac1567994d..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,13 +19,14 @@ 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
import com.duckduckgo.di.scopes.AppScope
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
import com.duckduckgo.duckchat.api.nativeinput.NativeInputState.ToggleSelection
-import com.duckduckgo.duckchat.impl.DuckChatInternal
import com.duckduckgo.duckchat.impl.ModelTier
import com.duckduckgo.duckchat.impl.ReportMetric
import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_CREATE_NEW_CHAT
@@ -137,9 +138,25 @@ 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()
+
+ fun sendDuckChatEntryPixel(
+ entryPoint: DuckChatEntryPoint,
+ opensNewTab: Boolean,
+ hasPrompt: Boolean,
+ duckAiEnabled: Boolean,
+ inputScreenEnabled: Boolean,
+ )
fun reportContextualSheetOpened()
fun reportContextualSheetDismissed()
fun reportContextualSheetSessionRestored()
@@ -200,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]. */
@@ -244,7 +264,7 @@ interface DuckChatPixels {
fun fireRecentChatDeleteConfirmed()
fun fireRecentChatDeleteCancelled()
fun fireCustomizeResponsesSelected(surface: DuckChatPixelSurface)
- fun fireOmnibarShown()
+ fun fireOmnibarShown(toggleVisible: Boolean)
fun fireOmnibarTextAreaFocused(landscape: Boolean)
fun fireOmnibarQuerySubmitted(query: String, defaultMode: ToggleSelection?)
fun fireOmnibarModeSwitched(directionToSearch: Boolean, hadText: Boolean)
@@ -260,12 +280,12 @@ interface DuckChatPixels {
class RealDuckChatPixels @Inject constructor(
private val pixel: Pixel,
private val duckChatFeatureRepository: DuckChatFeatureRepository,
- private val duckChatInternal: DuckChatInternal,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val statisticsUpdater: StatisticsUpdater,
private val duckAiMetricCollector: DuckAiMetricCollector,
private val termsOfServiceHandler: DuckChatTermsOfServiceHandler,
+ private val duckAiTabSessionRepository: DuckAiTabSessionRepository,
) : DuckChatPixels {
private fun fireCountAndDaily(
@@ -279,9 +299,56 @@ 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)
+ override fun sendDuckChatEntryPixel(
+ entryPoint: DuckChatEntryPoint,
+ opensNewTab: Boolean,
+ hasPrompt: Boolean,
+ duckAiEnabled: Boolean,
+ inputScreenEnabled: Boolean,
+ ) {
+ fireCountAndDaily(
+ DuckChatPixelName.DUCK_CHAT_ENTRY_POINT_COUNT,
+ DuckChatPixelName.DUCK_CHAT_ENTRY_POINT_DAILY,
+ mapOf(
+ DuckChatPixelParameters.ENTRY_SOURCE to entryPoint.toPixelValue(),
+ DuckChatPixelParameters.DUCK_AI_ENABLED to duckAiEnabled.toString(),
+ DuckChatPixelParameters.INPUT_SCREEN_ENABLED to inputScreenEnabled.toString(),
+ DuckChatPixelParameters.OPENS_NEW_TAB to opensNewTab.toString(),
+ DuckChatPixelParameters.HAS_PROMPT to hasPrompt.toString(),
+ ),
+ )
+ }
+
+ /**
+ * 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,
@@ -511,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)
@@ -525,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(),
+ )
}
}
@@ -724,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(
@@ -962,10 +1051,10 @@ class RealDuckChatPixels @Inject constructor(
DuckChatPixelName.DUCK_CHAT_RECENT_CHAT_DELETE_CANCELLED_DAILY,
)
- override fun fireOmnibarShown() = fireCountAndDaily(
+ override fun fireOmnibarShown(toggleVisible: Boolean) = fireCountAndDaily(
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SHOWN_COUNT,
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SHOWN_DAILY,
- mapOf(DuckChatPixelParameters.TOGGLE_VISIBLE to (duckChatInternal.resolvedTogglePosition() != null).toString()),
+ mapOf(DuckChatPixelParameters.TOGGLE_VISIBLE to toggleVisible.toString()),
)
override fun fireOmnibarTextAreaFocused(landscape: Boolean) {
@@ -1047,6 +1136,8 @@ class RealDuckChatPixels @Inject constructor(
enum class DuckChatPixelName(override val pixelName: String) : Pixel.PixelName {
DUCK_CHAT_OPEN("aichat_open"),
+ DUCK_CHAT_ENTRY_POINT_COUNT("m_aichat_entry_point_count"),
+ DUCK_CHAT_ENTRY_POINT_DAILY("m_aichat_entry_point_daily"),
DUCK_CHAT_OPEN_BROWSER_MENU("aichat_open_browser_menu"),
DUCK_CHAT_OPEN_NEW_TAB_MENU("aichat_open_new_tab_menu"),
DUCK_CHAT_OPEN_TAB_SWITCHER_FAB("aichat_open_tab_switcher_fab"),
@@ -1338,9 +1429,17 @@ enum class DuckChatPixelName(override val pixelName: String) : Pixel.PixelName {
}
object DuckChatPixelParameters {
+ const val ENTRY_SOURCE = "source"
+ const val DUCK_AI_ENABLED = "duck_ai_enabled"
+ const val INPUT_SCREEN_ENABLED = "input_screen_enabled"
+ const val OPENS_NEW_TAB = "opens_new_tab"
+ const val HAS_PROMPT = "has_prompt"
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"
@@ -1382,6 +1481,8 @@ class DuckChatParamRemovalPlugin @Inject constructor() : PixelParamRemovalPlugin
override fun names(): List>> {
return listOf(
DUCK_CHAT_OPEN.pixelName to PixelParameter.removeAtb(),
+ DuckChatPixelName.DUCK_CHAT_ENTRY_POINT_COUNT.pixelName to PixelParameter.removeAtb(),
+ DuckChatPixelName.DUCK_CHAT_ENTRY_POINT_DAILY.pixelName to PixelParameter.removeAtb(),
DUCK_CHAT_OPEN_BROWSER_MENU.pixelName to PixelParameter.removeAtb(),
DUCK_CHAT_OPEN_NEW_TAB_MENU.pixelName to PixelParameter.removeAtb(),
DUCK_CHAT_OPEN_TAB_SWITCHER_FAB.pixelName to PixelParameter.removeAtb(),
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsActivity.kt
index c1004283ccde..b1c0089ebc6a 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsActivity.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/subscription/DuckAiPaidSettingsActivity.kt
@@ -40,6 +40,7 @@ import com.duckduckgo.common.utils.edgetoedge.EdgeToEdgeProvider
import com.duckduckgo.common.utils.extensions.html
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams
import com.duckduckgo.duckchat.impl.R.string
import com.duckduckgo.duckchat.impl.databinding.ActivityDuckAiPaidSettingsBinding
@@ -168,7 +169,7 @@ class DuckAiPaidSettingsActivity : DuckDuckGoActivity() {
}
OpenDuckAi -> {
- duckChat.openDuckChat()
+ duckChat.openDuckChat(DuckChatEntryPoint.PAID_SETTINGS)
}
OpenDuckChatSettings -> {
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 07a4117f7d37..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
@@ -37,6 +39,7 @@ import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.plugins.ActivePluginPoint
import com.duckduckgo.di.scopes.ViewScope
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.NativeInputState.InteractionLock
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStateProvider
@@ -55,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
@@ -96,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,
@@ -153,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
@@ -261,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) {
@@ -279,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)
@@ -434,7 +448,7 @@ class NativeInputModeWidgetViewModel @Inject constructor(
}
fun openNewChat() {
- duckChatInternal.openNewDuckChatSession()
+ duckChatInternal.openNewDuckChatSession(DuckChatEntryPoint.ADDRESS_BAR_EDITING_STATE)
}
fun setDuckAiMode(isDuckAiMode: Boolean) {
@@ -552,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 b0a58894f7fb..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
@@ -42,12 +42,12 @@ class RealDuckChatPixelsAttachmentsTest {
private val testee = RealDuckChatPixels(
pixel = pixel,
duckChatFeatureRepository = duckChatFeatureRepository,
- duckChatInternal = mock(),
appCoroutineScope = coroutineTestRule.testScope,
dispatcherProvider = coroutineTestRule.testDispatcherProvider,
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 5c445c93420c..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
@@ -42,12 +42,12 @@ class RealDuckChatPixelsPickerTest {
private val testee = RealDuckChatPixels(
pixel = pixel,
duckChatFeatureRepository = duckChatFeatureRepository,
- duckChatInternal = mock(),
appCoroutineScope = coroutineTestRule.testScope,
dispatcherProvider = coroutineTestRule.testDispatcherProvider,
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 5ca5e8907d37..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,16 +45,17 @@ 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,
duckChatFeatureRepository = duckChatFeatureRepository,
- duckChatInternal = mock(),
appCoroutineScope = coroutineTestRule.testScope,
dispatcherProvider = coroutineTestRule.testDispatcherProvider,
statisticsUpdater = statisticsUpdater,
duckAiMetricCollector = duckAiMetricCollector,
termsOfServiceHandler = termsOfServiceHandler,
+ duckAiTabSessionRepository = duckAiTabSessionRepository,
)
private val surfaceParams = mapOf(DuckChatPixelParameters.SURFACE to "contextual_chat")
@@ -148,6 +155,9 @@ class RealDuckChatPixelsToolsTest {
hasText = true,
surface = DuckChatPixelSurface.CONTEXTUAL_CHAT,
defaultMode = null,
+ tabId = null,
+ pageType = DuckChatPixelPageType.CONTEXTUAL,
+ addressBarEntryPoint = null,
)
val params = mapOf(
@@ -158,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(
@@ -178,6 +259,9 @@ class RealDuckChatPixelsToolsTest {
hasText = true,
surface = DuckChatPixelSurface.ADDRESS_BAR,
defaultMode = null,
+ tabId = null,
+ pageType = DuckChatPixelPageType.NTP,
+ addressBarEntryPoint = null,
)
val params = mapOf(
@@ -186,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(
@@ -195,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(
@@ -206,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(
@@ -215,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(
@@ -237,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(
@@ -245,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 4b313d13c274..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
@@ -26,19 +26,21 @@ import androidx.lifecycle.Lifecycle.State.CREATED
import androidx.lifecycle.testing.TestLifecycleOwner
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
-import com.duckduckgo.app.statistics.pixels.Pixel
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
import com.duckduckgo.common.utils.AppUrl
import com.duckduckgo.cookies.api.CookieManagerProvider
import com.duckduckgo.duckchat.api.DuckAiHostProvider
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams
import com.duckduckgo.duckchat.api.InputMode
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.repository.AddressBarPickerAttributionRepository
import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository
import com.duckduckgo.duckchat.impl.store.DefaultTogglePosition
@@ -50,6 +52,7 @@ import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams
import com.duckduckgo.sync.api.DeviceSyncState
import com.squareup.moshi.Moshi
+import dagger.Lazy
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -74,7 +77,9 @@ import org.mockito.Mockito.spy
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.clearInvocations
+import org.mockito.kotlin.eq
import org.mockito.kotlin.never
+import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
@@ -93,7 +98,7 @@ class RealDuckChatTest {
private val dispatcherProvider = coroutineRule.testDispatcherProvider
private val mockGlobalActivityStarter: GlobalActivityStarter = mock()
private val mockContext: Context = mock()
- private val mockPixel: Pixel = mock()
+ private val mockDuckChatPixels: DuckChatPixels = mock()
private val mockIntent: Intent = mock()
private val mockBrowserNav: BrowserNav = mock()
private val imageUploadFeature: AIChatImageUploadFeature = FakeFeatureToggleFactory.create(AIChatImageUploadFeature::class.java)
@@ -103,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
@@ -137,7 +143,7 @@ class RealDuckChatTest {
mockContext,
true,
coroutineRule.testScope,
- mockPixel,
+ Lazy { mockDuckChatPixels },
imageUploadFeature,
mockBrowserNav,
mockDeviceSyncState,
@@ -146,6 +152,7 @@ class RealDuckChatTest {
mockAppBuildConfig,
mockVoiceSessionStateManager,
chatSuggestionsStore,
+ mockDuckAiTabSessionRepository,
),
)
coroutineRule.testScope.advanceUntilIdle()
@@ -610,7 +617,7 @@ class RealDuckChatTest {
@Test
fun whenOpenDuckChatCalledThenOpenDuckChat() = runTest {
- testee.openDuckChat()
+ testee.openDuckChat(DuckChatEntryPoint.PAID_SETTINGS)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -620,11 +627,76 @@ class RealDuckChatTest {
verify(mockContext).startActivity(mockIntent)
}
+ @Test
+ fun whenDuckChatEntryReportedThenDelegatesBoundedContextToDuckChatPixels() = runTest {
+ testee.reportDuckChatEntry(
+ entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT,
+ opensNewTab = false,
+ hasPrompt = true,
+ )
+
+ verify(mockDuckChatPixels).sendDuckChatEntryPixel(
+ entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT,
+ opensNewTab = false,
+ hasPrompt = true,
+ duckAiEnabled = true,
+ inputScreenEnabled = true,
+ )
+ }
+
+ @Test
+ fun whenContextualPromptOpensFullscreenThenContextualEntryIsReportedExactlyOnce() = runTest {
+ testee.openDuckChatWithAutoPrompt("contextual prompt", DuckChatEntryPoint.CONTEXTUAL_CHAT)
+
+ verify(mockDuckChatPixels, times(1)).sendDuckChatEntryPixel(
+ entryPoint = DuckChatEntryPoint.CONTEXTUAL_CHAT,
+ opensNewTab = true,
+ hasPrompt = true,
+ duckAiEnabled = true,
+ inputScreenEnabled = true,
+ )
+ }
+
+ @Test
+ fun publicOpenMethodsReportTheirNavigationAndPromptTruthTable() = runTest {
+ testee.openDuckChat(DuckChatEntryPoint.CHAT_HISTORY_NEW_CHAT)
+ testee.openDuckChatWithAutoPrompt("prompt", DuckChatEntryPoint.SUGGESTION_ASK_AI)
+ testee.openDuckChatWithAutoPrompt("", DuckChatEntryPoint.SYSTEM_SEARCH)
+ testee.openDuckChatWithAutoPrompt(" ", DuckChatEntryPoint.DIGITAL_ASSISTANT)
+ testee.openDuckChatWithAutoPrompt("!ai", DuckChatEntryPoint.DIRECT_URL)
+ testee.openDuckChatWithAutoPrompt("!ai prompt", DuckChatEntryPoint.ADDRESS_BAR_PROMPT)
+ testee.openDuckChatWithPrefill("prefill", DuckChatEntryPoint.DIRECT_URL)
+ testee.openVoiceDuckChat(DuckChatEntryPoint.VOICE)
+
+ val entryPoints = argumentCaptor()
+ val hasPrompts = argumentCaptor()
+ verify(mockDuckChatPixels, times(8)).sendDuckChatEntryPixel(
+ entryPoint = entryPoints.capture(),
+ opensNewTab = eq(true),
+ hasPrompt = hasPrompts.capture(),
+ duckAiEnabled = eq(true),
+ inputScreenEnabled = eq(true),
+ )
+ assertEquals(
+ listOf(
+ DuckChatEntryPoint.CHAT_HISTORY_NEW_CHAT to false,
+ DuckChatEntryPoint.SUGGESTION_ASK_AI to true,
+ DuckChatEntryPoint.SYSTEM_SEARCH to false,
+ DuckChatEntryPoint.DIGITAL_ASSISTANT to false,
+ DuckChatEntryPoint.DIRECT_URL to false,
+ DuckChatEntryPoint.ADDRESS_BAR_PROMPT to true,
+ DuckChatEntryPoint.DIRECT_URL to false,
+ DuckChatEntryPoint.VOICE to false,
+ ),
+ entryPoints.allValues.zip(hasPrompts.allValues),
+ )
+ }
+
@Test
fun whenOpenDuckChatCalledWithCustomHostThenUrlUsesCustomHost() = runTest {
whenever(mockDuckAiHostProvider.getHost()).thenReturn("staging.duck.ai")
- testee.openDuckChat()
+ testee.openDuckChat(DuckChatEntryPoint.PAID_SETTINGS)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -639,7 +711,7 @@ class RealDuckChatTest {
val thirtyMinutesAgo = System.currentTimeMillis() - (30 * 60 * 1000L)
whenever(mockDuckChatFeatureRepository.lastSessionTimestamp()).thenReturn(thirtyMinutesAgo)
- testee.openDuckChat()
+ testee.openDuckChat(DuckChatEntryPoint.PAID_SETTINGS)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -651,7 +723,7 @@ class RealDuckChatTest {
@Test
fun whenOpenVoiceDuckChatCalledThenOpenDuckChatWithVoiceModeUrl() = runTest {
- testee.openVoiceDuckChat()
+ testee.openVoiceDuckChat(DuckChatEntryPoint.VOICE)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -666,7 +738,7 @@ class RealDuckChatTest {
val thirtyMinutesAgo = System.currentTimeMillis() - (30 * 60 * 1000L)
whenever(mockDuckChatFeatureRepository.lastSessionTimestamp()).thenReturn(thirtyMinutesAgo)
- testee.openVoiceDuckChat()
+ testee.openVoiceDuckChat(DuckChatEntryPoint.VOICE)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -678,7 +750,7 @@ class RealDuckChatTest {
@Test
fun whenOpenDuckChatCalledWithQueryThenDuckChatOpenedWithQuery() = runTest {
- testee.openDuckChatWithPrefill(query = "example")
+ testee.openDuckChatWithPrefill(query = "example", entryPoint = DuckChatEntryPoint.DIRECT_URL)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -694,7 +766,7 @@ class RealDuckChatTest {
duckChatFeature.keepSession().setRawStoredState(State(enable = false))
testee.onPrivacyConfigDownloaded()
- testee.openDuckChatWithPrefill(query = "example !ai")
+ testee.openDuckChatWithPrefill(query = "example !ai", entryPoint = DuckChatEntryPoint.DIRECT_URL)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -710,7 +782,7 @@ class RealDuckChatTest {
duckChatFeature.keepSession().setRawStoredState(State(enable = false))
testee.onPrivacyConfigDownloaded()
- testee.openDuckChatWithPrefill(query = "example !g")
+ testee.openDuckChatWithPrefill(query = "example !g", entryPoint = DuckChatEntryPoint.DIRECT_URL)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -725,7 +797,7 @@ class RealDuckChatTest {
duckChatFeature.self().setRawStoredState(State(enable = true, settings = SETTINGS_JSON))
testee.onPrivacyConfigDownloaded()
- testee.openDuckChatWithPrefill(query = "!ai !image")
+ testee.openDuckChatWithPrefill(query = "!ai !image", entryPoint = DuckChatEntryPoint.DIRECT_URL)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -769,7 +841,7 @@ class RealDuckChatTest {
@Test
fun whenOpenDuckChatCalledWithQueryAndAutoPromptThenDuckChatOpenedWithQueryAndAutoPrompt() = runTest {
- testee.openDuckChatWithAutoPrompt(query = "example")
+ testee.openDuckChatWithAutoPrompt(query = "example", entryPoint = DuckChatEntryPoint.SUGGESTION_ASK_AI)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -857,7 +929,7 @@ class RealDuckChatTest {
testee.onPrivacyConfigDownloaded()
coroutineRule.testScope.advanceUntilIdle()
- testee.openDuckChat()
+ testee.openDuckChat(DuckChatEntryPoint.PAID_SETTINGS)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -873,7 +945,7 @@ class RealDuckChatTest {
testee.onPrivacyConfigDownloaded()
coroutineRule.testScope.advanceUntilIdle()
- testee.openDuckChatWithPrefill(query = "example")
+ testee.openDuckChatWithPrefill(query = "example", entryPoint = DuckChatEntryPoint.DIRECT_URL)
verify(mockBrowserNav).openDuckChat(
mockContext,
@@ -889,7 +961,7 @@ class RealDuckChatTest {
testee.onPrivacyConfigDownloaded()
coroutineRule.testScope.advanceUntilIdle()
- testee.openVoiceDuckChat()
+ testee.openVoiceDuckChat(DuckChatEntryPoint.VOICE)
verify(mockBrowserNav).openDuckChat(
mockContext,
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt
index d6c7cd906dca..6a337bb91b69 100644
--- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/contextual/DuckChatContextualViewModelTest.kt
@@ -21,6 +21,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import com.duckduckgo.common.test.CoroutineTestRule
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.impl.DuckChatInternal
import com.duckduckgo.duckchat.impl.R
import com.duckduckgo.duckchat.impl.contextual.suggestions.ContextualSuggestedPrompt
@@ -1232,12 +1233,24 @@ class DuckChatContextualViewModelTest {
}
@Test
- fun `when full mode requested then expanded pixel is fired`() = runTest {
+ fun `when full mode requested then expanded pixel and entry point are fired`() = runTest {
testee.onFullModeRequested()
coroutineRule.testDispatcher.scheduler.advanceUntilIdle()
verify(duckChatPixels).reportContextualSheetExpanded()
+ verify(duckChatInternal).reportDuckChatEntry(DuckChatEntryPoint.CONTEXTUAL_CHAT, opensNewTab = true, hasPrompt = false)
+ }
+
+ @Test
+ fun `when full mode requested with a prompt already sent then entry point reports hasPrompt true`() = runTest {
+ testee.onPromptSent("hello")
+ coroutineRule.testDispatcher.scheduler.advanceUntilIdle()
+
+ testee.onFullModeRequested()
+ coroutineRule.testDispatcher.scheduler.advanceUntilIdle()
+
+ verify(duckChatInternal).reportDuckChatEntry(DuckChatEntryPoint.CONTEXTUAL_CHAT, opensNewTab = true, hasPrompt = true)
}
@Test
@@ -1604,12 +1617,14 @@ class DuckChatContextualViewModelTest {
testee.onSheetOpened("tab-1")
verify(duckChatPixels).reportContextualSheetOpened()
verify(duckChatPixels).reportContextualAskAboutPageShown()
+ verify(duckChatInternal, never()).reportDuckChatEntry(any(), any(), any())
}
@Test
fun `when reopenSheet called then contextual opened pixel is fired`() = runTest {
testee.onSheetReopened()
verify(duckChatPixels).reportContextualSheetOpened()
+ verify(duckChatInternal, never()).reportDuckChatEntry(any(), any(), any())
}
@Test
@@ -2367,6 +2382,12 @@ class DuckChatContextualViewModelTest {
assertEquals("tab-1", command.sourceTabId)
cancelAndIgnoreRemainingEvents()
}
+
+ verify(duckChatInternal, times(1)).reportDuckChatEntry(
+ DuckChatEntryPoint.CHAT_HISTORY_OPEN_CHAT,
+ opensNewTab = true,
+ hasPrompt = false,
+ )
}
@Test
@@ -2611,9 +2632,14 @@ class DuckChatContextualViewModelTest {
}
override fun isEnabled(): Boolean = true
- override fun openDuckChat() = Unit
- override fun openDuckChatWithAutoPrompt(query: String) = Unit
- override fun openDuckChatWithPrefill(query: String) = Unit
+ override fun openDuckChat(entryPoint: com.duckduckgo.duckchat.api.DuckChatEntryPoint) = Unit
+ override fun openDuckChatWithAutoPrompt(query: String, entryPoint: com.duckduckgo.duckchat.api.DuckChatEntryPoint) = Unit
+ override fun openDuckChatWithPrefill(query: String, entryPoint: com.duckduckgo.duckchat.api.DuckChatEntryPoint) = Unit
+ override fun reportDuckChatEntry(
+ entryPoint: com.duckduckgo.duckchat.api.DuckChatEntryPoint,
+ opensNewTab: Boolean,
+ hasPrompt: Boolean,
+ ) = Unit
override fun getDuckChatUrl(
query: String,
autoPrompt: Boolean,
@@ -2638,7 +2664,7 @@ class DuckChatContextualViewModelTest {
override suspend fun isStandaloneMigrationCompleted(): Boolean = true
override suspend fun setChatSuggestionsUserSetting(enabled: Boolean) = Unit
override fun observeChatSuggestionsUserSettingEnabled(): Flow = flowOf(true)
- override fun openVoiceDuckChat() { }
+ override fun openVoiceDuckChat(entryPoint: com.duckduckgo.duckchat.api.DuckChatEntryPoint) { }
override fun isVoiceChatSessionActive(tabId: String): Boolean = false
override val activeVoiceChatSessions: Flow> = flowOf(emptySet())
override fun observeTriggerVoiceChatSessionEnd(): Flow = emptyFlow()
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt
index abed85e79eef..c96c7c26022d 100644
--- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt
@@ -25,6 +25,7 @@ import com.duckduckgo.browser.api.wideevents.BrowserInteractionsPlugin
import com.duckduckgo.browsermode.api.BrowserMode
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.common.utils.plugins.PluginPoint
+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
@@ -1192,12 +1193,13 @@ class RealDuckChatJSHelperTest {
method,
id,
data,
+ mode = Mode.CONTEXTUAL,
pageContext = viewModel.updatedPageContext,
),
)
verify(mockDataStore).updateUserPreferences(payloadString)
- verify(mockDuckChat).openNewDuckChatSession()
+ verify(mockDuckChat).openNewDuckChatSession(DuckChatEntryPoint.CONTEXTUAL_CHAT)
}
@Test
@@ -1212,11 +1214,12 @@ class RealDuckChatJSHelperTest {
method,
id,
null,
+ mode = Mode.CONTEXTUAL,
pageContext = viewModel.updatedPageContext,
),
)
verify(mockDataStore).updateUserPreferences(null)
- verify(mockDuckChat).openNewDuckChatSession()
+ verify(mockDuckChat).openNewDuckChatSession(DuckChatEntryPoint.CONTEXTUAL_CHAT)
}
@Test
@@ -1232,11 +1235,42 @@ class RealDuckChatJSHelperTest {
method,
id,
data,
+ mode = Mode.CONTEXTUAL,
pageContext = viewModel.updatedPageContext,
),
)
verify(mockDataStore).updateUserPreferences(null)
- verify(mockDuckChat).openNewDuckChatSession()
+ verify(mockDuckChat).openNewDuckChatSession(DuckChatEntryPoint.CONTEXTUAL_CHAT)
+ }
+
+ @Test
+ fun whenOpenAIChatFromOtherWebpageThenOpenDuckChatWithDirectUrlEntryPoint() = runTest {
+ assertNull(
+ testee.processJsCallbackMessage(
+ "aiChat",
+ "openAIChat",
+ "123",
+ null,
+ mode = Mode.FULL,
+ ),
+ )
+
+ verify(mockDuckChat).openNewDuckChatSession(DuckChatEntryPoint.DIRECT_URL)
+ }
+
+ @Test
+ fun whenOpenAIChatFromContextualThenOpenDuckChatWithContextualEntryPoint() = runTest {
+ assertNull(
+ testee.processJsCallbackMessage(
+ "aiChat",
+ "openAIChat",
+ "123",
+ null,
+ mode = Mode.CONTEXTUAL,
+ ),
+ )
+
+ verify(mockDuckChat).openNewDuckChatSession(DuckChatEntryPoint.CONTEXTUAL_CHAT)
}
@Test
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt
index 0d22e24825f7..60ce39968161 100644
--- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt
@@ -18,6 +18,7 @@ package com.duckduckgo.duckchat.impl.messaging.fakes
import android.net.Uri
import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
@@ -44,18 +45,20 @@ class FakeDuckChat(
override fun isEnabled(): Boolean = enabled
- override fun openDuckChat() {
+ override fun openDuckChat(entryPoint: DuckChatEntryPoint) {
openDuckChatCalls.add(Unit)
}
- override fun openDuckChatWithAutoPrompt(query: String) {
+ override fun openDuckChatWithAutoPrompt(query: String, entryPoint: DuckChatEntryPoint) {
openDuckChatWithAutoPromptCalls.add(query)
}
- override fun openDuckChatWithPrefill(query: String) {
+ override fun openDuckChatWithPrefill(query: String, entryPoint: DuckChatEntryPoint) {
openDuckChatWithPrefillCalls.add(query)
}
+ override fun reportDuckChatEntry(entryPoint: DuckChatEntryPoint, opensNewTab: Boolean, hasPrompt: Boolean) { }
+
override fun getDuckChatUrl(
query: String,
autoPrompt: Boolean,
@@ -118,7 +121,7 @@ class FakeDuckChat(
override fun observeChatSuggestionsUserSettingEnabled(): Flow = chatSuggestionsUserSettingEnabled
- override fun openVoiceDuckChat() { }
+ override fun openVoiceDuckChat(entryPoint: DuckChatEntryPoint) { }
override fun isVoiceChatSessionActive(tabId: String): Boolean = false
override val activeVoiceChatSessions: Flow> = MutableStateFlow(emptySet())
override fun observeTriggerVoiceChatSessionEnd(): Flow = emptyFlow()
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt
index bfec32de0467..f82ce04a7d44 100644
--- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChatInternal.kt
@@ -18,6 +18,7 @@ package com.duckduckgo.duckchat.impl.messaging.fakes
import android.net.Uri
import androidx.lifecycle.LifecycleOwner
+import com.duckduckgo.duckchat.api.DuckChatEntryPoint
import com.duckduckgo.duckchat.api.DuckChatInputModeState
import com.duckduckgo.duckchat.api.InputMode
import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
@@ -63,13 +64,15 @@ class FakeDuckChatInternal(
var openDuckChatCalls: Int = 0
private set
- override fun openDuckChat() {
+ override fun openDuckChat(entryPoint: DuckChatEntryPoint) {
openDuckChatCalls += 1
}
- override fun openDuckChatWithAutoPrompt(query: String) { }
+ override fun openDuckChatWithAutoPrompt(query: String, entryPoint: DuckChatEntryPoint) { }
- override fun openDuckChatWithPrefill(query: String) { }
+ override fun openDuckChatWithPrefill(query: String, entryPoint: DuckChatEntryPoint) { }
+
+ override fun reportDuckChatEntry(entryPoint: DuckChatEntryPoint, opensNewTab: Boolean, hasPrompt: Boolean) { }
override fun getDuckChatUrl(query: String, autoPrompt: Boolean, sidebar: Boolean): String {
return "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5"
@@ -148,7 +151,7 @@ class FakeDuckChatInternal(
override fun closeDuckChat() { }
- override fun openNewDuckChatSession() { }
+ override fun openNewDuckChatSession(entryPoint: DuckChatEntryPoint) { }
override fun observeCloseEvent(lifecycleOwner: LifecycleOwner, onClose: () -> Unit) { }
@@ -214,7 +217,7 @@ class FakeDuckChatInternal(
override fun observeChatSuggestionsUserSettingEnabled(): Flow = chatSuggestionsUserSettingEnabled
- override fun openVoiceDuckChat() { }
+ override fun openVoiceDuckChat(entryPoint: DuckChatEntryPoint) { }
override fun isVoiceChatSessionActive(tabId: String): Boolean = false
override val activeVoiceChatSessions: Flow> = MutableStateFlow(emptySet())
override fun observeTriggerVoiceChatSessionEnd(): Flow = emptyFlow()
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/metric/nativeinput/MetricsNativeInputEventListenerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/metric/nativeinput/MetricsNativeInputEventListenerTest.kt
index 3ffca356c49d..738334df8d5d 100644
--- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/metric/nativeinput/MetricsNativeInputEventListenerTest.kt
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/metric/nativeinput/MetricsNativeInputEventListenerTest.kt
@@ -16,26 +16,37 @@
package com.duckduckgo.duckchat.impl.metric.nativeinput
+import com.duckduckgo.duckchat.api.DuckChatInputModeState
+import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
import com.duckduckgo.duckchat.impl.metric.nativeinput.discovery.InputScreenDiscoveryFunnel
import com.duckduckgo.duckchat.impl.metric.nativeinput.usage.InputScreenSessionUsageMetric
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels
+import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
+import org.mockito.kotlin.whenever
class MetricsNativeInputEventListenerTest {
private val duckChatPixels: DuckChatPixels = mock()
+ private val duckChatInputModeState: DuckChatInputModeState = mock()
+ private val inputModeCapability = MutableStateFlow(NativeInputState.InputMode.SEARCH_ONLY)
private val sessionUsageMetric: InputScreenSessionUsageMetric = mock()
private val discoveryFunnel: InputScreenDiscoveryFunnel = mock()
private val testee = MetricsNativeInputEventListener(
duckChatPixels = duckChatPixels,
+ duckChatInputModeState = duckChatInputModeState,
sessionUsageMetric = sessionUsageMetric,
discoveryFunnel = discoveryFunnel,
)
+ init {
+ whenever(duckChatInputModeState.inputModeCapability).thenReturn(inputModeCapability)
+ }
+
@Test
fun whenSearchSubmittedThenUsageAndDiscoveryMetricsAreUpdated() {
testee.onSearchSubmitted("query")
@@ -44,4 +55,22 @@ class MetricsNativeInputEventListenerTest {
verify(discoveryFunnel).onSearchSubmitted()
verifyNoInteractions(duckChatPixels)
}
+
+ @Test
+ fun whenNativeInputShownWithToggleCapabilityThenOmnibarShownReportsToggleVisible() {
+ inputModeCapability.value = NativeInputState.InputMode.SEARCH_AND_DUCK_AI
+
+ testee.onNativeInputShown(landscape = false)
+
+ verify(duckChatPixels).fireOmnibarShown(toggleVisible = true)
+ }
+
+ @Test
+ fun whenNativeInputShownWithSearchOnlyCapabilityThenOmnibarShownReportsToggleNotVisible() {
+ inputModeCapability.value = NativeInputState.InputMode.SEARCH_ONLY
+
+ testee.onNativeInputShown(landscape = false)
+
+ verify(duckChatPixels).fireOmnibarShown(toggleVisible = false)
+ }
}
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 75660182eac9..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,9 +18,10 @@ 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
-import com.duckduckgo.duckchat.impl.DuckChatInternal
import com.duckduckgo.duckchat.impl.ReportMetric
import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_ACCEPT_TERMS_AND_CONDITIONS
import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_CREATE_NEW_CHAT
@@ -55,6 +56,8 @@ import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_CONTEXTUAL
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_SETTING_AUTOMATIC_PAGE_CONTENT_DISABLED_DAILY
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_SETTING_AUTOMATIC_PAGE_CONTENT_ENABLED_COUNT
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_SETTING_AUTOMATIC_PAGE_CONTENT_ENABLED_DAILY
+import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_ENTRY_POINT_COUNT
+import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_ENTRY_POINT_DAILY
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_KEYBOARD_RETURN_PRESSED
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_OPEN
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_OPEN_HISTORY
@@ -68,9 +71,13 @@ import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.clearInvocations
+import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
@@ -88,7 +95,7 @@ class RealDuckChatPixelsTest {
private val statisticsUpdater: StatisticsUpdater = mock()
private val duckAiMetricCollector: DuckAiMetricCollector = mock()
private val mockTermsOfServiceHandler: DuckChatTermsOfServiceHandler = mock()
- private val mockDuckChatInternal: DuckChatInternal = mock()
+ private val mockDuckAiTabSessionRepository: DuckAiTabSessionRepository = mock()
private lateinit var testee: RealDuckChatPixels
@@ -99,12 +106,12 @@ class RealDuckChatPixelsTest {
testee = RealDuckChatPixels(
pixel = mockPixel,
duckChatFeatureRepository = mockDuckChatFeatureRepository,
- duckChatInternal = mockDuckChatInternal,
appCoroutineScope = coroutineRule.testScope,
dispatcherProvider = coroutineRule.testDispatcherProvider,
statisticsUpdater = statisticsUpdater,
duckAiMetricCollector = duckAiMetricCollector,
termsOfServiceHandler = mockTermsOfServiceHandler,
+ duckAiTabSessionRepository = mockDuckAiTabSessionRepository,
)
}
@@ -263,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
@@ -371,6 +390,48 @@ class RealDuckChatPixelsTest {
verify(mockPixel).fire(DuckChatPixelName.DUCK_CHAT_CONTEXTUAL_SUGGESTIONS_TIMED_OUT_DAILY, type = Pixel.PixelType.Daily())
}
+ @Test
+ fun `when sendDuckChatEntryPixel then fires count and daily with bounded entry context`() = runTest {
+ testee.sendDuckChatEntryPixel(
+ entryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT,
+ opensNewTab = false,
+ hasPrompt = true,
+ duckAiEnabled = true,
+ inputScreenEnabled = true,
+ )
+
+ advanceUntilIdle()
+
+ val params = mapOf(
+ "source" to "address_bar_prompt",
+ "duck_ai_enabled" to "true",
+ "input_screen_enabled" to "true",
+ "opens_new_tab" to "false",
+ "has_prompt" to "true",
+ )
+ verify(mockPixel).fire(DUCK_CHAT_ENTRY_POINT_COUNT, params)
+ verify(mockPixel).fire(DUCK_CHAT_ENTRY_POINT_DAILY, params, type = Pixel.PixelType.Daily())
+ }
+
+ @Test
+ fun `every DuckChatEntryPoint maps to its lower snake case wire value`() = runTest {
+ DuckChatEntryPoint.entries.forEach { entryPoint ->
+ clearInvocations(mockPixel)
+
+ testee.sendDuckChatEntryPixel(entryPoint, opensNewTab = true, hasPrompt = false, duckAiEnabled = true, inputScreenEnabled = true)
+ advanceUntilIdle()
+
+ val parameters = argumentCaptor