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>() + verify(mockPixel).fire( + eq(DUCK_CHAT_ENTRY_POINT_COUNT), + parameters.capture(), + eq(emptyMap()), + eq(Pixel.PixelType.Count), + ) + assertEquals(entryPoint.name.lowercase(), parameters.firstValue["source"]) + } + } + @Test fun `when reportContextualSuggestionsCatalogLoadFailed then fires count and daily`() = runTest { testee.reportContextualSuggestionsCatalogLoadFailed() @@ -458,9 +519,7 @@ class RealDuckChatPixelsTest { @Test fun whenFireOmnibarShownWithToggleVisibleThenParamsReflectIt() = runTest { - whenever(mockDuckChatInternal.resolvedTogglePosition()).thenReturn(ToggleSelection.SEARCH) - - testee.fireOmnibarShown() + testee.fireOmnibarShown(toggleVisible = true) advanceUntilIdle() @@ -475,9 +534,7 @@ class RealDuckChatPixelsTest { @Test fun whenFireOmnibarShownWithNoToggleThenParamsReflectIt() = runTest { - whenever(mockDuckChatInternal.resolvedTogglePosition()).thenReturn(null) - - testee.fireOmnibarShown() + testee.fireOmnibarShown(toggleVisible = false) advanceUntilIdle() diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt index 72a00083b8c4..72f98504e98c 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/NativeInputModeWidgetViewModelTest.kt @@ -19,6 +19,7 @@ package com.duckduckgo.duckchat.impl.ui import android.content.Context import android.view.View import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.tabs.model.TabEntity @@ -38,6 +39,7 @@ import com.duckduckgo.browsermode.api.BrowserModeStateHolder import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.ActivePluginPoint import com.duckduckgo.duckchat.api.DuckAiFeatureState +import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.duckchat.api.nativeinput.NativeInputState import com.duckduckgo.duckchat.api.nativeinput.NativeInputStateProvider import com.duckduckgo.duckchat.api.nativeinput.NativeInputStatePublisher @@ -58,6 +60,7 @@ import com.duckduckgo.duckchat.impl.nativeinput.NativeInputHost import com.duckduckgo.duckchat.impl.nativeinput.NativeInputPlugin import com.duckduckgo.duckchat.impl.nativeinput.RealNativeInputStateStore import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName +import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelPageType import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelSurface import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.ui.nativeinput.suggestions.ChatSuggestion @@ -110,6 +113,7 @@ class NativeInputModeWidgetViewModelTest { val coroutineRule = CoroutineTestRule() private val duckChatInternal: DuckChatInternal = mock() + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private val duckAiFeatureState: DuckAiFeatureState = mock() private val subscriptions: Subscriptions = mock() private val pendingNativePromptStore: PendingNativePromptStore = mock() @@ -181,6 +185,7 @@ class NativeInputModeWidgetViewModelTest { fakePlugins = plugins return NativeInputModeWidgetViewModel( duckChatInternal = duckChatInternal, + duckDuckGoUrlDetector = duckDuckGoUrlDetector, duckAiFeatureState = duckAiFeatureState, subscriptions = subscriptions, pendingNativePromptStore = pendingNativePromptStore, @@ -1689,7 +1694,12 @@ class NativeInputModeWidgetViewModelTest { advanceUntilIdle() viewModel.setSelectedTool(Tool.IMAGE_GENERATION.rawValue) - viewModel.fireSubmissionPixels(hasText = true, hasImageAttachment = true, hasFileAttachment = false) + viewModel.fireSubmissionPixels( + hasText = true, + hasImageAttachment = true, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "image_generation", @@ -1700,6 +1710,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, + tabId = tabId, + pageType = DuckChatPixelPageType.DUCK_AI, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels).fireImageGenerationSubmitted(any()) verify(duckChatPixels, never()).fireWebSearchSubmitted(any()) @@ -1714,7 +1727,12 @@ class NativeInputModeWidgetViewModelTest { viewModel.configure(tabId = tabId, isDuckAiMode = true, isBottom = false) advanceUntilIdle() - viewModel.fireSubmissionPixels(hasText = true, hasImageAttachment = false, hasFileAttachment = false) + viewModel.fireSubmissionPixels( + hasText = true, + hasImageAttachment = false, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "none", @@ -1725,6 +1743,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, + tabId = tabId, + pageType = DuckChatPixelPageType.DUCK_AI, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels, never()).fireImageGenerationSubmitted(any()) verify(duckChatPixels, never()).fireWebSearchSubmitted(any()) @@ -1739,7 +1760,12 @@ class NativeInputModeWidgetViewModelTest { viewModel.configure(tabId = "tab-A", isDuckAiMode = false, isBottom = false) advanceUntilIdle() - viewModel.fireSubmissionPixels(hasText = true, hasImageAttachment = false, hasFileAttachment = false) + viewModel.fireSubmissionPixels( + hasText = true, + hasImageAttachment = false, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "none", @@ -1750,6 +1776,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = NativeInputState.ToggleSelection.SEARCH, + tabId = "tab-A", + pageType = DuckChatPixelPageType.NTP, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) } @@ -1758,7 +1787,12 @@ class NativeInputModeWidgetViewModelTest { whenever(duckChatInternal.resolvedTogglePosition()).thenReturn(NativeInputState.ToggleSelection.SEARCH) advanceUntilIdle() - testee.fireSubmissionPixels(hasText = true, hasImageAttachment = false, hasFileAttachment = false) + testee.fireSubmissionPixels( + hasText = true, + hasImageAttachment = false, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "none", @@ -1769,6 +1803,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.ADDRESS_BAR, defaultMode = null, + tabId = "test-tab", + pageType = DuckChatPixelPageType.NTP, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) } @@ -1782,7 +1819,12 @@ class NativeInputModeWidgetViewModelTest { advanceUntilIdle() viewModel.setSelectedTool(Tool.WEB_SEARCH.rawValue) - viewModel.fireSubmissionPixels(hasText = true, hasImageAttachment = false, hasFileAttachment = false) + viewModel.fireSubmissionPixels( + hasText = true, + hasImageAttachment = false, + hasFileAttachment = false, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, + ) verify(duckChatPixels).firePromptSubmitted( selectedTool = "web_search", @@ -1793,6 +1835,9 @@ class NativeInputModeWidgetViewModelTest { hasText = true, surface = DuckChatPixelSurface.DUCK_AI, defaultMode = null, + tabId = tabId, + pageType = DuckChatPixelPageType.DUCK_AI, + addressBarEntryPoint = DuckChatEntryPoint.ADDRESS_BAR_PROMPT, ) verify(duckChatPixels).fireWebSearchSubmitted(any()) verify(duckChatPixels, never()).fireImageGenerationSubmitted(any()) diff --git a/new-tab-page/new-tab-page-impl/build.gradle b/new-tab-page/new-tab-page-impl/build.gradle index 90e93b7e627b..b7ddcc1ca340 100644 --- a/new-tab-page/new-tab-page-impl/build.gradle +++ b/new-tab-page/new-tab-page-impl/build.gradle @@ -27,6 +27,7 @@ dependencies { ksp project(':anvil-ksp') implementation project(":new-tab-page-api") implementation project(':browser-api') + implementation project(':duckchat-api') implementation project(':browser-mode-api') implementation project(':common-utils') implementation project(':saved-sites-api') diff --git a/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/shortcuts/NewTabShortcuts.kt b/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/shortcuts/NewTabShortcuts.kt index 68fdfea71a20..8675eb7d570f 100644 --- a/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/shortcuts/NewTabShortcuts.kt +++ b/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/shortcuts/NewTabShortcuts.kt @@ -21,6 +21,8 @@ import com.duckduckgo.anvil.annotations.ContributesActivePlugin import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckChat +import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue import com.duckduckgo.newtabpage.api.NewTabPageShortcutPlugin @@ -38,6 +40,7 @@ import javax.inject.Inject class AIChatNewTabShortcutPlugin @Inject constructor( private val browserNav: BrowserNav, private val setting: AIChatNewTabShortcutSetting, + private val duckChat: DuckChat, ) : NewTabPageShortcutPlugin { inner class AIChatShortcut() : NewTabShortcut { @@ -51,6 +54,7 @@ class AIChatNewTabShortcutPlugin @Inject constructor( } override fun onClick(context: Context) { + duckChat.reportDuckChatEntry(DuckChatEntryPoint.ADDRESS_BAR_SHORTCUT_CHIP, opensNewTab = false, hasPrompt = false) context.startActivity(browserNav.openInCurrentTab(context, AI_CHAT_URL)) } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt index 151da9caa293..b24ec5e31cc1 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt @@ -71,6 +71,7 @@ import com.duckduckgo.downloads.api.DownloadsFileActions import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload import com.duckduckgo.duckchat.api.DuckChat +import com.duckduckgo.duckchat.api.DuckChatEntryPoint import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging @@ -606,7 +607,7 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD } private fun goToDuckAI() { - duckChat.openDuckChat() + duckChat.openDuckChat(DuckChatEntryPoint.PAID_SETTINGS) } private fun computeUserSettings(id: String) {