From 1f266b175f256ffc83c0c994e9fd9f85cfcdda51 Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 17 Nov 2025 07:30:35 +0000 Subject: [PATCH 1/6] Duck.ai - support standalone migration --- .../app/browser/DuckDuckGoUrlDetector.kt | 4 + .../app/browser/DuckDuckGoUrlDetector.kt | 6 + .../com/duckduckgo/common/utils/AppUrl.kt | 1 + .../duckduckgo/duckchat/impl/RealDuckChat.kt | 9 ++ .../duckchat/impl/feature/DuckChatFeature.kt | 8 ++ .../duckchat/impl/helper/DuckChatJSHelper.kt | 119 +++++++++++++++++- .../DuckChatContentScopeJsMessageHandler.kt | 7 ++ .../impl/ui/DuckChatWebViewFragment.kt | 11 +- .../impl/helper/RealDuckChatJSHelperTest.kt | 90 +++++++++++++ ...uckChatContentScopeJsMessageHandlerTest.kt | 11 +- 10 files changed, 261 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt index 25bed4091dbe..f7d95274b7a3 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt @@ -38,6 +38,10 @@ class DuckDuckGoUrlDetectorImpl @Inject constructor() : DuckDuckGoUrlDetector { return runCatching { AppUrl.Url.HOST == url.toHttpUrl().topPrivateDomain() }.getOrElse { false } } + override fun isDuckAiUrl(url: String): Boolean { + return runCatching { AppUrl.Url.HOST_DUCKAI == url.toHttpUrl().topPrivateDomain() }.getOrElse { false } + } + override fun isDuckDuckGoQueryUrl(uri: String): Boolean { return isDuckDuckGoUrl(uri) && hasQuery(uri) } diff --git a/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt b/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt index 7a29c676c25e..15e284995310 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt @@ -33,6 +33,12 @@ interface DuckDuckGoUrlDetector { */ fun isDuckDuckGoUrl(url: String): Boolean + /** + * This method takes a [url] and returns `true` or `false`. + * @return `true` if the given [url] belongs to the duck.ai domain (apex or subdomain) and `false` otherwise. + */ + fun isDuckAiUrl(url: String): Boolean + /** * This method takes a [uri] and returns `true` or `false`. * @return `true` if the given [uri] is a DuckDuckGo query and `false` diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt index a59393e45bda..28012d4bc1a3 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt @@ -20,6 +20,7 @@ class AppUrl { object Url { const val HOST = "duckduckgo.com" + const val HOST_DUCKAI = "duck.ai" const val API = "https://$HOST" const val HOME = "https://$HOST" const val COOKIES = "https://$HOST" 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 c66843ebbaf1..38c7d07c36fc 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 @@ -181,6 +181,11 @@ interface DuckChatInternal : DuckChat { */ fun isImageUploadEnabled(): Boolean + /** + * Returns whether standalone migration is supported. + */ + fun isStandaloneMigrationEnabled(): Boolean + /** * Returns the time a Duck Chat session should be kept alive */ @@ -315,6 +320,7 @@ class RealDuckChat @Inject constructor( private var isAddressBarEntryPointEnabled: Boolean = false private var isVoiceSearchEntryPointEnabled: Boolean = false private var isImageUploadEnabled: Boolean = false + private var isStandaloneMigrationEnabled: Boolean = false private var keepSessionAliveInMinutes: Int = DEFAULT_SESSION_ALIVE private var clearChatHistory: Boolean = true private var inputScreenMainButtonsEnabled = false @@ -462,6 +468,8 @@ class RealDuckChat @Inject constructor( override fun isImageUploadEnabled(): Boolean = isImageUploadEnabled + override fun isStandaloneMigrationEnabled(): Boolean = isStandaloneMigrationEnabled + override fun keepSessionIntervalInMinutes() = keepSessionAliveInMinutes override fun openDuckChat() { @@ -701,6 +709,7 @@ class RealDuckChat @Inject constructor( isAddressBarEntryPointEnabled = settingsJson?.addressBarEntryPoint ?: false isVoiceSearchEntryPointEnabled = duckChatFeature.duckAiVoiceSearch().isEnabled() isImageUploadEnabled = imageUploadFeature.self().isEnabled() + isStandaloneMigrationEnabled = duckChatFeature.standaloneMigration().isEnabled() keepSession.value = duckChatFeature.keepSession().isEnabled() keepSessionAliveInMinutes = settingsJson?.sessionTimeoutMinutes ?: DEFAULT_SESSION_ALIVE diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt index 0f549f23b1eb..de5bb67137a6 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt @@ -117,6 +117,14 @@ interface DuckChatFeature { @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun duckAiVoiceSearch(): Toggle + + /** + * @return `true` when standalone migration is supported + * If the remote feature is not present defaults to `false` + */ + @Toggle.DefaultValue(DefaultFeatureValue.FALSE) + fun standaloneMigration(): Toggle + /** * @return `true` when the "Hide AI Generated Images" option should be visible in AI Features Settings. */ 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 bf2f7f15870a..a8a30307f683 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 @@ -16,6 +16,7 @@ package com.duckduckgo.duckchat.impl.helper +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.impl.ChatState import com.duckduckgo.duckchat.impl.ChatState.HIDE @@ -29,6 +30,7 @@ import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.SubscriptionEventData import com.squareup.anvil.annotations.ContributesBinding import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.json.JSONObject import java.util.regex.Pattern import javax.inject.Inject @@ -56,8 +58,9 @@ class RealDuckChatJSHelper @Inject constructor( private val duckChatPixels: DuckChatPixels, private val dataStore: DuckChatDataStore, private val duckAiMetricCollector: DuckAiMetricCollector, + private val dispatchers: DispatcherProvider, ) : DuckChatJSHelper { - + private val migrationItems = mutableListOf() override suspend fun processJsCallbackMessage( featureName: String, method: String, @@ -127,6 +130,22 @@ class RealDuckChatJSHelper @Inject constructor( null } + METHOD_STORE_MIGRATION_DATA -> id?.let { + getStoreMigrationDataResponse(featureName, method, it, data) + } + + METHOD_GET_MIGRATION_INFO -> id?.let { + getMigrationInfoResponse(featureName, method, it) + } + + METHOD_GET_MIGRATION_DATA_BY_INDEX -> id?.let { + getMigrationDataByIndexResponse(featureName, method, it, data) + } + + METHOD_CLEAR_MIGRATION_DATA -> id?.let { + getClearMigrationDataResponse(featureName, method, it) + } + else -> null } @@ -172,6 +191,7 @@ class RealDuckChatJSHelper @Inject constructor( put(SUPPORTS_NATIVE_CHAT_INPUT, false) put(SUPPORTS_CHAT_ID_RESTORATION, duckChat.isDuckChatFullScreenModeEnabled()) put(SUPPORTS_IMAGE_UPLOAD, duckChat.isImageUploadEnabled()) + put(SUPPORTS_STANDALONE_MIGRATION, duckChat.isStandaloneMigrationEnabled()) } return JsCallbackData(jsonPayload, featureName, method, id) } @@ -215,6 +235,90 @@ class RealDuckChatJSHelper @Inject constructor( } } + /** + * Accept incoming JSON payload { "serializedMigrationFile": "..." } + * Store the string value in an ordered list for later retrieval + */ + private suspend fun getStoreMigrationDataResponse( + featureName: String, + method: String, + id: String, + data: JSONObject?, + ): JsCallbackData { + return withContext(dispatchers.io()) { + val item = data?.optString(SERIALIZED_MIGRATION_FILE) + val jsonPayload = JSONObject() + if (item != null && item != JSONObject.NULL) { + migrationItems.add(item) + jsonPayload.put(OK, true) + } else { + jsonPayload.put(OK, false) + jsonPayload.put(REASON, "Missing or invalid serializedMigrationFile") + } + JsCallbackData(jsonPayload, featureName, method, id) + } + } + + /** + * Return the count of strings previously stored. + * It's ok to return 0 if no items have been stored + */ + private suspend fun getMigrationInfoResponse( + featureName: String, + method: String, + id: String, + ): JsCallbackData { + return withContext(dispatchers.io()) { + val count = migrationItems.size + val jsonPayload = JSONObject().apply { + put(OK, true) + put(COUNT, count) + } + JsCallbackData(jsonPayload, featureName, method, id) + } + } + + /** + * Try to lookup a string by index + * - when found, return { ok: true, serializedMigrationFile: '...' } + * - when missing, return { ok: false, reason: '...' } + */ + private suspend fun getMigrationDataByIndexResponse( + featureName: String, + method: String, + id: String, + data: JSONObject?, + ): JsCallbackData { + return withContext(dispatchers.io()) { + val index = data?.optInt(INDEX, -1) ?: -1 + val value = migrationItems.getOrNull(index) + val jsonPayload = JSONObject() + if (value == null) { + jsonPayload.put(OK, false) + jsonPayload.put(REASON, "nothing at index: $index") + } else { + jsonPayload.put(OK, true) + jsonPayload.put(SERIALIZED_MIGRATION_FILE, value) + } + JsCallbackData(jsonPayload, featureName, method, id) + } + } + + /** + * Clear migration data, returning { ok: true } when complete + */ + private suspend fun getClearMigrationDataResponse( + featureName: String, + method: String, + id: String, + ): JsCallbackData { + return withContext(dispatchers.io()) { + migrationItems.clear() + val jsonPayload = JSONObject().apply { put(OK, true) } + JsCallbackData(jsonPayload, featureName, method, id) + } + } + companion object { const val DUCK_CHAT_FEATURE_NAME = "aiChat" private const val METHOD_GET_AI_CHAT_NATIVE_HANDOFF_DATA = "getAIChatNativeHandoffData" @@ -234,6 +338,7 @@ class RealDuckChatJSHelper @Inject constructor( private const val SUPPORTS_NATIVE_CHAT_INPUT = "supportsNativeChatInput" private const val SUPPORTS_IMAGE_UPLOAD = "supportsImageUpload" private const val SUPPORTS_CHAT_ID_RESTORATION = "supportsURLChatIDRestoration" + private const val SUPPORTS_STANDALONE_MIGRATION = "supportsStandaloneMigration" private const val REPORT_METRIC = "reportMetric" private const val PLATFORM = "platform" private const val ANDROID = "android" @@ -244,5 +349,17 @@ class RealDuckChatJSHelper @Inject constructor( private const val SUBSCRIPTION_NEW_CHAT = "submitNewChatAction" private const val SUBSCRIPTION_HISTORY = "openDuckAiHistory" private const val SUBSCRIPTION_DUCK_AI_SETTINGS = "openDuckAiSettings" + private const val OK = "ok" + private const val REASON = "reason" + + // Migration messaging constants + private const val METHOD_STORE_MIGRATION_DATA = "storeMigrationData" + private const val METHOD_GET_MIGRATION_INFO = "getMigrationInfo" + private const val METHOD_GET_MIGRATION_DATA_BY_INDEX = "getMigrationDataByIndex" + private const val METHOD_CLEAR_MIGRATION_DATA = "clearMigrationData" + + private const val SERIALIZED_MIGRATION_FILE = "serializedMigrationFile" + private const val COUNT = "count" + private const val INDEX = "index" } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt index c5181203ccde..2943824bd3ef 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt @@ -41,6 +41,7 @@ class DuckChatContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ override val allowedDomains: List = listOf( AppUrl.Url.HOST, + AppUrl.Url.HOST_DUCKAI, ) override val featureName: String = "aiChat" @@ -56,6 +57,12 @@ class DuckChatContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ "showChatInput", "reportMetric", "openKeyboard", + + // migration handlers + "storeMigrationData", + "getMigrationInfo", + "getMigrationDataByIndex", + "clearMigrationData", ) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt index 31ea8d298b28..d2280eecea35 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt @@ -47,6 +47,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -130,6 +131,9 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c @Inject lateinit var duckChatJSHelper: DuckChatJSHelper + @Inject + lateinit var duckDuckGoUrlDetector: DuckDuckGoUrlDetector + @Inject lateinit var subscriptionsHandler: SubscriptionsHandler @@ -209,7 +213,12 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c view?.requestFocusNodeHref(resultMsg) val newWindowUrl = resultMsg?.data?.getString("url") if (newWindowUrl != null) { - startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl)) + if (duckDuckGoUrlDetector.isDuckAiUrl(newWindowUrl)) { + // Allow Duck.ai links to load within the same WebView (in-sheet navigation) + simpleWebview.loadUrl(newWindowUrl) + } else { + startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl)) + } return true } return false 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 d7b71f6b7090..4ea04cefe9d8 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 @@ -59,6 +59,7 @@ class RealDuckChatJSHelperTest { dataStore = mockDataStore, duckChatPixels = mockDuckChatPixels, duckAiMetricCollector = mockDuckAiMetricCollector, + dispatchers = coroutineRule.testDispatcherProvider, ) @Test @@ -186,6 +187,7 @@ class RealDuckChatJSHelperTest { put("supportsNativeChatInput", false) put("supportsURLChatIDRestoration", false) put("supportsImageUpload", false) + put("supportsStandaloneMigration", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -244,6 +246,7 @@ class RealDuckChatJSHelperTest { put("supportsNativeChatInput", false) put("supportsURLChatIDRestoration", true) put("supportsImageUpload", false) + put("supportsStandaloneMigration", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -406,6 +409,7 @@ class RealDuckChatJSHelperTest { put("supportsNativeChatInput", false) put("supportsURLChatIDRestoration", false) put("supportsImageUpload", true) + put("supportsStandaloneMigration", false) } assertEquals(expectedPayload.toString(), result!!.params.toString()) @@ -537,4 +541,90 @@ class RealDuckChatJSHelperTest { assertEquals("openDuckAiSettings", result.subscriptionName) assertEquals(DUCK_CHAT_FEATURE_NAME, result.featureName) } + + @Test + fun whenStoreMigrationDataThenItemIsStoredAndInfoCountReflectsIt() = runTest { + val featureName = "aiChat" + val id = "1" + + // store two items + val item1 = JSONObject(mapOf("serializedMigrationFile" to "file-1")) + val item2 = JSONObject(mapOf("serializedMigrationFile" to "file-2")) + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, item1) + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, item2) + + // get count + val info = testee.processJsCallbackMessage(featureName, "getMigrationInfo", id, null) + val expected = JSONObject().apply { + put("ok", true) + put("count", 2) + } + assertEquals(expected.toString(), info!!.params.toString()) + } + + @Test + fun whenGetMigrationDataByIndexWithValidIndexThenReturnItem() = runTest { + val featureName = "aiChat" + val id = "1" + + // store items + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-1"))) + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-2"))) + + val result = testee.processJsCallbackMessage(featureName, "getMigrationDataByIndex", id, JSONObject(mapOf("index" to 1))) + val expected = JSONObject().apply { + put("ok", true) + put("serializedMigrationFile", "file-2") + } + assertEquals(expected.toString(), result!!.params.toString()) + } + + @Test + fun whenGetMigrationDataByIndexWithInvalidIndexThenReturnEmptyPayload() = runTest { + val featureName = "aiChat" + val id = "1" + + // store one item + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-1"))) + + // negative index + val negative = testee.processJsCallbackMessage(featureName, "getMigrationDataByIndex", id, JSONObject(mapOf("index" to -1))) + val expectedNegative = JSONObject().apply { + put("ok", false) + put("reason", "nothing at index: -1") + } + assertEquals(expectedNegative.toString(), negative!!.params.toString()) + + // out of range index + val outOfRange = testee.processJsCallbackMessage(featureName, "getMigrationDataByIndex", id, JSONObject(mapOf("index" to 5))) + val expectedOutOfRange = JSONObject().apply { + put("ok", false) + put("reason", "nothing at index: 5") + } + assertEquals(expectedOutOfRange.toString(), outOfRange!!.params.toString()) + } + + @Test + fun whenClearMigrationDataThenItemsRemovedAndCountZero() = runTest { + val featureName = "aiChat" + val id = "1" + + // store items + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-1"))) + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-2"))) + + // clear + val clearResult = testee.processJsCallbackMessage(featureName, "clearMigrationData", id, null) + // clear returns ok true + val expectedClear = JSONObject().apply { put("ok", true) } + assertEquals(expectedClear.toString(), clearResult!!.params.toString()) + + // count is zero + val info = testee.processJsCallbackMessage(featureName, "getMigrationInfo", id, null) + val expected = JSONObject().apply { + put("ok", true) + put("count", 0) + } + assertEquals(expected.toString(), info!!.params.toString()) + } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt index 97d3ddce4df4..ba07ccd56abf 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt @@ -28,8 +28,9 @@ class DuckChatContentScopeJsMessageHandlerTest { @Test fun `only allow duckduckgo dot com domains`() { val domains = handler.allowedDomains - assertTrue(domains.size == 1) - assertTrue(domains.first() == "duckduckgo.com") + assertTrue(domains.size == 2) + assertTrue(domains[0] == "duckduckgo.com") + assertTrue(domains[1] == "duck.ai") } @Test @@ -40,7 +41,7 @@ class DuckChatContentScopeJsMessageHandlerTest { @Test fun `only contains valid methods`() { val methods = handler.methods - assertTrue(methods.size == 10) + assertTrue(methods.size == 14) assertTrue(methods[0] == "getAIChatNativeHandoffData") assertTrue(methods[1] == "getAIChatNativeConfigValues") assertTrue(methods[2] == "openAIChat") @@ -51,6 +52,10 @@ class DuckChatContentScopeJsMessageHandlerTest { assertTrue(methods[7] == "showChatInput") assertTrue(methods[8] == "reportMetric") assertTrue(methods[9] == "openKeyboard") + assertTrue(methods[10] == "storeMigrationData") + assertTrue(methods[11] == "getMigrationInfo") + assertTrue(methods[12] == "getMigrationDataByIndex") + assertTrue(methods[13] == "clearMigrationData") } private val callback = object : JsMessageCallback() { From c0fb8a4bd913f3d6d766e23f397f82903a4e9bdf Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 17 Nov 2025 08:12:10 +0000 Subject: [PATCH 2/6] linting --- .../java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt index de5bb67137a6..578df68130d9 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt @@ -117,7 +117,6 @@ interface DuckChatFeature { @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun duckAiVoiceSearch(): Toggle - /** * @return `true` when standalone migration is supported * If the remote feature is not present defaults to `false` From 8d33349b8a3a702127f57c341979000d3e7a2c24 Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 17 Nov 2025 08:15:08 +0000 Subject: [PATCH 3/6] tests --- .../duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt | 1 + 1 file changed, 1 insertion(+) 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 4ea04cefe9d8..9e0adbab82f0 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 @@ -217,6 +217,7 @@ class RealDuckChatJSHelperTest { put("supportsNativeChatInput", false) put("supportsURLChatIDRestoration", false) put("supportsImageUpload", false) + put("supportsStandaloneMigration", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) From 1e6bf74d969c172b7c093ae212e044974a0d28a5 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 19 Nov 2025 13:56:35 +0000 Subject: [PATCH 4/6] Move things around and separate individual handlers --- .../app/browser/DuckDuckGoUrlDetector.kt | 4 - .../app/browser/DuckDuckGoUrlDetector.kt | 6 - .../com/duckduckgo/common/utils/AppUrl.kt | 1 - .../ContentScopeScriptsJsMessaging.kt | 3 + .../duckchat/impl/DuckChatConstants.kt | 29 +++ .../duckduckgo/duckchat/impl/RealDuckChat.kt | 13 ++ .../duckchat/impl/helper/DuckChatJSHelper.kt | 118 ----------- .../DuckChatContentScopeJsMessageHandler.kt | 9 +- .../DuckChatStandaloneJsMessageHandlers.kt | 197 ++++++++++++++++++ .../duckchat/impl/ui/DuckChatWebViewClient.kt | 9 + .../impl/ui/DuckChatWebViewFragment.kt | 3 +- .../impl/ui/DuckChatWebViewViewModel.kt | 5 + 12 files changed, 259 insertions(+), 138 deletions(-) create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatConstants.kt create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatStandaloneJsMessageHandlers.kt diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt index f7d95274b7a3..25bed4091dbe 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt @@ -38,10 +38,6 @@ class DuckDuckGoUrlDetectorImpl @Inject constructor() : DuckDuckGoUrlDetector { return runCatching { AppUrl.Url.HOST == url.toHttpUrl().topPrivateDomain() }.getOrElse { false } } - override fun isDuckAiUrl(url: String): Boolean { - return runCatching { AppUrl.Url.HOST_DUCKAI == url.toHttpUrl().topPrivateDomain() }.getOrElse { false } - } - override fun isDuckDuckGoQueryUrl(uri: String): Boolean { return isDuckDuckGoUrl(uri) && hasQuery(uri) } diff --git a/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt b/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt index 15e284995310..7a29c676c25e 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt @@ -33,12 +33,6 @@ interface DuckDuckGoUrlDetector { */ fun isDuckDuckGoUrl(url: String): Boolean - /** - * This method takes a [url] and returns `true` or `false`. - * @return `true` if the given [url] belongs to the duck.ai domain (apex or subdomain) and `false` otherwise. - */ - fun isDuckAiUrl(url: String): Boolean - /** * This method takes a [uri] and returns `true` or `false`. * @return `true` if the given [uri] is a DuckDuckGo query and `false` diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt index 28012d4bc1a3..a59393e45bda 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt @@ -20,7 +20,6 @@ class AppUrl { object Url { const val HOST = "duckduckgo.com" - const val HOST_DUCKAI = "duck.ai" const val API = "https://$HOST" const val HOME = "https://$HOST" const val COOKIES = "https://$HOST" diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt index c1e5d87dd5e7..d57b4bb5a0db 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt @@ -65,6 +65,9 @@ class ContentScopeScriptsJsMessaging @Inject constructor( message: String, secret: String, ) { + logcat { + "Marcos is $message" + } try { val adapter = moshi.adapter(JsMessage::class.java) val jsMessage = adapter.fromJson(message) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatConstants.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatConstants.kt new file mode 100644 index 000000000000..079b746d8202 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/DuckChatConstants.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 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 + +object DuckChatConstants { + const val HOST_DUCK_AI = "duck.ai" + + object StandaloneConstants { + const val SERIALIZED_MIGRATION_FILE = "serializedMigrationFile" + const val COUNT = "count" + const val INDEX = "index" + const val OK = "ok" + const val REASON = "reason" + } +} 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 38c7d07c36fc..01582ae95add 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 @@ -34,6 +34,7 @@ import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.api.DuckAiFeatureState import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams +import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI import com.duckduckgo.duckchat.impl.feature.AIChatImageUploadFeature import com.duckduckgo.duckchat.impl.feature.DuckChatFeature import com.duckduckgo.duckchat.impl.inputscreen.newaddressbaroption.NewAddressBarCallback @@ -65,6 +66,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import logcat.logcat +import okhttp3.HttpUrl.Companion.toHttpUrl import javax.inject.Inject interface DuckChatInternal : DuckChat { @@ -211,6 +213,12 @@ interface DuckChatInternal : DuckChat { */ fun isDuckChatFeatureEnabled(): Boolean + /** + * This method takes a [url] and returns `true` or `false`. + * @return `true` if the given [url] belongs to the duck.ai domain (apex or subdomain) or if it's the revoke url and `false` otherwise. + */ + fun isDuckAiUrl(url: String): Boolean + /** * Indicates whether Input Screen will present the input box at the bottom, if user has the omnibar also set to the bottom position. * Otherwise, the input box will be at the top. @@ -429,6 +437,10 @@ class RealDuckChat @Inject constructor( } } + override fun isDuckAiUrl(url: String): Boolean { + return runCatching { HOST_DUCK_AI == url.toHttpUrl().topPrivateDomain() || url == REVOKE_URL }.getOrElse { false } + } + override fun isAddressBarEntryPointEnabled(): Boolean = isAddressBarEntryPointEnabled override fun isVoiceSearchEntryPointEnabled(): Boolean = isVoiceSearchEntryPointEnabled @@ -772,5 +784,6 @@ class RealDuckChat @Inject constructor( private const val BANG_QUERY_NAME = "bang" private const val BANG_QUERY_VALUE = "true" private const val DEFAULT_SESSION_ALIVE = 60 + private const val REVOKE_URL = "https://duckduckgo.com/revoke-duckai-access" } } 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 a8a30307f683..b26b0a3e29e3 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 @@ -16,7 +16,6 @@ package com.duckduckgo.duckchat.impl.helper -import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.impl.ChatState import com.duckduckgo.duckchat.impl.ChatState.HIDE @@ -30,7 +29,6 @@ import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.SubscriptionEventData import com.squareup.anvil.annotations.ContributesBinding import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import org.json.JSONObject import java.util.regex.Pattern import javax.inject.Inject @@ -58,9 +56,7 @@ class RealDuckChatJSHelper @Inject constructor( private val duckChatPixels: DuckChatPixels, private val dataStore: DuckChatDataStore, private val duckAiMetricCollector: DuckAiMetricCollector, - private val dispatchers: DispatcherProvider, ) : DuckChatJSHelper { - private val migrationItems = mutableListOf() override suspend fun processJsCallbackMessage( featureName: String, method: String, @@ -129,23 +125,6 @@ class RealDuckChatJSHelper @Inject constructor( } null } - - METHOD_STORE_MIGRATION_DATA -> id?.let { - getStoreMigrationDataResponse(featureName, method, it, data) - } - - METHOD_GET_MIGRATION_INFO -> id?.let { - getMigrationInfoResponse(featureName, method, it) - } - - METHOD_GET_MIGRATION_DATA_BY_INDEX -> id?.let { - getMigrationDataByIndexResponse(featureName, method, it, data) - } - - METHOD_CLEAR_MIGRATION_DATA -> id?.let { - getClearMigrationDataResponse(featureName, method, it) - } - else -> null } @@ -234,91 +213,6 @@ class RealDuckChatJSHelper @Inject constructor( } } } - - /** - * Accept incoming JSON payload { "serializedMigrationFile": "..." } - * Store the string value in an ordered list for later retrieval - */ - private suspend fun getStoreMigrationDataResponse( - featureName: String, - method: String, - id: String, - data: JSONObject?, - ): JsCallbackData { - return withContext(dispatchers.io()) { - val item = data?.optString(SERIALIZED_MIGRATION_FILE) - val jsonPayload = JSONObject() - if (item != null && item != JSONObject.NULL) { - migrationItems.add(item) - jsonPayload.put(OK, true) - } else { - jsonPayload.put(OK, false) - jsonPayload.put(REASON, "Missing or invalid serializedMigrationFile") - } - JsCallbackData(jsonPayload, featureName, method, id) - } - } - - /** - * Return the count of strings previously stored. - * It's ok to return 0 if no items have been stored - */ - private suspend fun getMigrationInfoResponse( - featureName: String, - method: String, - id: String, - ): JsCallbackData { - return withContext(dispatchers.io()) { - val count = migrationItems.size - val jsonPayload = JSONObject().apply { - put(OK, true) - put(COUNT, count) - } - JsCallbackData(jsonPayload, featureName, method, id) - } - } - - /** - * Try to lookup a string by index - * - when found, return { ok: true, serializedMigrationFile: '...' } - * - when missing, return { ok: false, reason: '...' } - */ - private suspend fun getMigrationDataByIndexResponse( - featureName: String, - method: String, - id: String, - data: JSONObject?, - ): JsCallbackData { - return withContext(dispatchers.io()) { - val index = data?.optInt(INDEX, -1) ?: -1 - val value = migrationItems.getOrNull(index) - val jsonPayload = JSONObject() - if (value == null) { - jsonPayload.put(OK, false) - jsonPayload.put(REASON, "nothing at index: $index") - } else { - jsonPayload.put(OK, true) - jsonPayload.put(SERIALIZED_MIGRATION_FILE, value) - } - JsCallbackData(jsonPayload, featureName, method, id) - } - } - - /** - * Clear migration data, returning { ok: true } when complete - */ - private suspend fun getClearMigrationDataResponse( - featureName: String, - method: String, - id: String, - ): JsCallbackData { - return withContext(dispatchers.io()) { - migrationItems.clear() - val jsonPayload = JSONObject().apply { put(OK, true) } - JsCallbackData(jsonPayload, featureName, method, id) - } - } - companion object { const val DUCK_CHAT_FEATURE_NAME = "aiChat" private const val METHOD_GET_AI_CHAT_NATIVE_HANDOFF_DATA = "getAIChatNativeHandoffData" @@ -349,17 +243,5 @@ class RealDuckChatJSHelper @Inject constructor( private const val SUBSCRIPTION_NEW_CHAT = "submitNewChatAction" private const val SUBSCRIPTION_HISTORY = "openDuckAiHistory" private const val SUBSCRIPTION_DUCK_AI_SETTINGS = "openDuckAiSettings" - private const val OK = "ok" - private const val REASON = "reason" - - // Migration messaging constants - private const val METHOD_STORE_MIGRATION_DATA = "storeMigrationData" - private const val METHOD_GET_MIGRATION_INFO = "getMigrationInfo" - private const val METHOD_GET_MIGRATION_DATA_BY_INDEX = "getMigrationDataByIndex" - private const val METHOD_CLEAR_MIGRATION_DATA = "clearMigrationData" - - private const val SERIALIZED_MIGRATION_FILE = "serializedMigrationFile" - private const val COUNT = "count" - private const val INDEX = "index" } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt index 2943824bd3ef..9036166de810 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt @@ -19,6 +19,7 @@ package com.duckduckgo.duckchat.impl.messaging import com.duckduckgo.common.utils.AppUrl import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI import com.duckduckgo.js.messaging.api.JsMessage import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessageHandler @@ -41,7 +42,7 @@ class DuckChatContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ override val allowedDomains: List = listOf( AppUrl.Url.HOST, - AppUrl.Url.HOST_DUCKAI, + HOST_DUCK_AI, ) override val featureName: String = "aiChat" @@ -57,12 +58,6 @@ class DuckChatContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ "showChatInput", "reportMetric", "openKeyboard", - - // migration handlers - "storeMigrationData", - "getMigrationInfo", - "getMigrationDataByIndex", - "clearMigrationData", ) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatStandaloneJsMessageHandlers.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatStandaloneJsMessageHandlers.kt new file mode 100644 index 000000000000..2976a537d10f --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatStandaloneJsMessageHandlers.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 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.messaging + +import com.duckduckgo.common.utils.AppUrl +import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI +import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.COUNT +import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.INDEX +import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.OK +import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.REASON +import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.SERIALIZED_MIGRATION_FILE +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessageCallback +import com.duckduckgo.js.messaging.api.JsMessageHandler +import com.duckduckgo.js.messaging.api.JsMessaging +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import org.json.JSONObject +import javax.inject.Inject + +interface StandaloneDuckChatStore { + fun storeMigrationItem(item: String) + fun getMigrationItemByIndex(index: Int): String? + fun getMigrationItemCount(): Int + fun clearMigrationItems() +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class InMemoryStandaloneStore @Inject constructor() : StandaloneDuckChatStore { + private val migrationItems = mutableListOf() + + override fun storeMigrationItem(item: String) { + migrationItems.add(item) + } + + override fun getMigrationItemByIndex(index: Int): String? { + return migrationItems.getOrNull(index) + } + + override fun getMigrationItemCount(): Int = migrationItems.size + + override fun clearMigrationItems() { + migrationItems.clear() + } +} + +@ContributesMultibinding(AppScope::class) +class StoreMigrationDataHandler @Inject constructor( + private val standaloneDuckChatStore: StandaloneDuckChatStore, +) : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (jsMessage.id.isNullOrEmpty()) return + + val item = jsMessage.params.optString(SERIALIZED_MIGRATION_FILE) + val jsonPayload = JSONObject() + if (item != null && item != JSONObject.NULL) { + standaloneDuckChatStore.storeMigrationItem(item) + jsonPayload.put(OK, true) + } else { + jsonPayload.put(OK, false) + jsonPayload.put(REASON, "Missing or invalid serializedMigrationFile") + } + jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)) + } + + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("storeMigrationData") + } +} + +@ContributesMultibinding(AppScope::class) +class GetMigrationInfoHandler @Inject constructor( + private val standaloneDuckChatStore: StandaloneDuckChatStore, +) : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (jsMessage.id.isNullOrEmpty()) return + + val count = standaloneDuckChatStore.getMigrationItemCount() + val jsonPayload = JSONObject().apply { + put(OK, true) + put(COUNT, count) + } + + jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)) + } + + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("getMigrationInfo") + } +} + +@ContributesMultibinding(AppScope::class) +class GetMigrationDataByIndexHandler @Inject constructor( + private val standaloneDuckChatStore: StandaloneDuckChatStore, +) : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (jsMessage.id.isNullOrEmpty()) return + val index = jsMessage.params.optInt(INDEX, -1) + val value = standaloneDuckChatStore.getMigrationItemByIndex(index) + val jsonPayload = JSONObject() + if (value == null) { + jsonPayload.put(OK, false) + jsonPayload.put(REASON, "nothing at index: $index") + } else { + jsonPayload.put(OK, true) + jsonPayload.put(SERIALIZED_MIGRATION_FILE, value) + } + jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)) + } + + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("getMigrationDataByIndex") + } +} + +@ContributesMultibinding(AppScope::class) +class ClearMigrationDataHandler @Inject constructor( + private val standaloneDuckChatStore: StandaloneDuckChatStore, +) : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (jsMessage.id.isNullOrEmpty()) return + standaloneDuckChatStore.clearMigrationItems() + val jsonPayload = JSONObject().apply { put(OK, true) } + jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)) + } + + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("clearMigrationData") + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt index 096c9d338841..d7f321ada7f4 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt @@ -22,6 +22,7 @@ import android.webkit.WebViewClient import androidx.annotation.UiThread import com.duckduckgo.browser.api.JsInjectorPlugin import com.duckduckgo.common.utils.plugins.PluginPoint +import logcat.logcat import javax.inject.Inject class DuckChatWebViewClient @Inject constructor( @@ -38,4 +39,12 @@ class DuckChatWebViewClient @Inject constructor( it.onPageStarted(webView, url, null) } } + + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + logcat { "Marcos url is $url and ${view?.url}" } + super.onPageFinished(view, url) + } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt index d2280eecea35..56a555aa080b 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt @@ -213,8 +213,7 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c view?.requestFocusNodeHref(resultMsg) val newWindowUrl = resultMsg?.data?.getString("url") if (newWindowUrl != null) { - if (duckDuckGoUrlDetector.isDuckAiUrl(newWindowUrl)) { - // Allow Duck.ai links to load within the same WebView (in-sheet navigation) + if (viewModel.handleOnSameWebView(newWindowUrl)) { simpleWebview.loadUrl(newWindowUrl) } else { startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl)) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt index b4620d8eae8d..33716b37fc5e 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt @@ -65,6 +65,11 @@ class DuckChatWebViewViewModel @Inject constructor( observeSubscriptionChanges() } + fun handleOnSameWebView(url: String): Boolean { + // Allow Duck.ai links to load within the same WebView (in-sheet navigation) + return duckChat.isDuckAiUrl(url) + } + private fun observeSubscriptionChanges() { subscriptions.getSubscriptionStatusFlow() .distinctUntilChanged() From df08487933036f1458d62890c4e0fc7f12153a13 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 19 Nov 2025 16:22:50 +0000 Subject: [PATCH 5/6] Tests --- .../ContentScopeScriptsJsMessaging.kt | 3 - .../duckduckgo/duckchat/impl/RealDuckChat.kt | 6 +- .../duckchat/impl/helper/DuckChatJSHelper.kt | 2 + .../DuckChatStandaloneJsMessageHandlers.kt | 40 +++-- .../duckchat/impl/ui/DuckChatWebViewClient.kt | 9 -- .../impl/ui/DuckChatWebViewFragment.kt | 24 ++- .../impl/ui/DuckChatWebViewViewModel.kt | 17 +- .../impl/ui/DuckChatWebViewViewModelTest.kt | 67 ++++++++ .../duckchat/impl/RealDuckChatTest.kt | 8 + .../impl/helper/RealDuckChatJSHelperTest.kt | 116 ++++---------- .../ClearMigrationDataHandlerTest.kt | 107 +++++++++++++ ...uckChatContentScopeJsMessageHandlerTest.kt | 6 +- .../GetMigrationDataByIndexHandlerTest.kt | 130 +++++++++++++++ .../messaging/GetMigrationInfoHandlerTest.kt | 130 +++++++++++++++ .../messaging/InMemoryStandaloneStoreTest.kt | 46 ++++++ .../StoreMigrationDataHandlerTest.kt | 150 ++++++++++++++++++ 16 files changed, 727 insertions(+), 134 deletions(-) create mode 100644 duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/ClearMigrationDataHandlerTest.kt create mode 100644 duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/GetMigrationDataByIndexHandlerTest.kt create mode 100644 duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/GetMigrationInfoHandlerTest.kt create mode 100644 duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/InMemoryStandaloneStoreTest.kt create mode 100644 duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/StoreMigrationDataHandlerTest.kt diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt index d57b4bb5a0db..c1e5d87dd5e7 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/messaging/ContentScopeScriptsJsMessaging.kt @@ -65,9 +65,6 @@ class ContentScopeScriptsJsMessaging @Inject constructor( message: String, secret: String, ) { - logcat { - "Marcos is $message" - } try { val adapter = moshi.adapter(JsMessage::class.java) val jsMessage = adapter.fromJson(message) 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 01582ae95add..b431668f2810 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 @@ -215,9 +215,9 @@ interface DuckChatInternal : DuckChat { /** * This method takes a [url] and returns `true` or `false`. - * @return `true` if the given [url] belongs to the duck.ai domain (apex or subdomain) or if it's the revoke url and `false` otherwise. + * @return `true` if the given [url] can be handled in the duck ai webview and `false` otherwise. */ - fun isDuckAiUrl(url: String): Boolean + fun canHandleOnAiWebView(url: String): Boolean /** * Indicates whether Input Screen will present the input box at the bottom, if user has the omnibar also set to the bottom position. @@ -437,7 +437,7 @@ class RealDuckChat @Inject constructor( } } - override fun isDuckAiUrl(url: String): Boolean { + override fun canHandleOnAiWebView(url: String): Boolean { return runCatching { HOST_DUCK_AI == url.toHttpUrl().topPrivateDomain() || url == REVOKE_URL }.getOrElse { false } } 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 b26b0a3e29e3..78cf2ff2b081 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 @@ -125,6 +125,7 @@ class RealDuckChatJSHelper @Inject constructor( } null } + else -> null } @@ -213,6 +214,7 @@ class RealDuckChatJSHelper @Inject constructor( } } } + companion object { const val DUCK_CHAT_FEATURE_NAME = "aiChat" private const val METHOD_GET_AI_CHAT_NATIVE_HANDOFF_DATA = "getAIChatNativeHandoffData" diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatStandaloneJsMessageHandlers.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatStandaloneJsMessageHandlers.kt index 2976a537d10f..c072219ebe94 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatStandaloneJsMessageHandlers.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatStandaloneJsMessageHandlers.kt @@ -75,17 +75,21 @@ class StoreMigrationDataHandler @Inject constructor( jsMessageCallback: JsMessageCallback?, ) { if (jsMessage.id.isNullOrEmpty()) return - val item = jsMessage.params.optString(SERIALIZED_MIGRATION_FILE) - val jsonPayload = JSONObject() - if (item != null && item != JSONObject.NULL) { + + val result = if (!item.isNullOrEmpty() && item != JSONObject.NULL.toString()) { standaloneDuckChatStore.storeMigrationItem(item) - jsonPayload.put(OK, true) + true } else { - jsonPayload.put(OK, false) - jsonPayload.put(REASON, "Missing or invalid serializedMigrationFile") + false } - jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)) + + val payload = JSONObject().apply { + put(OK, result) + if (!result) put(REASON, "Missing or invalid serializedMigrationFile") + } + + jsMessaging.onResponse(JsCallbackData(payload, featureName, jsMessage.method, jsMessage.id!!)) } override val allowedDomains: List = @@ -113,12 +117,12 @@ class GetMigrationInfoHandler @Inject constructor( if (jsMessage.id.isNullOrEmpty()) return val count = standaloneDuckChatStore.getMigrationItemCount() - val jsonPayload = JSONObject().apply { + val payload = JSONObject().apply { put(OK, true) put(COUNT, count) } - jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)) + jsMessaging.onResponse(JsCallbackData(payload, featureName, jsMessage.method, jsMessage.id!!)) } override val allowedDomains: List = @@ -146,15 +150,17 @@ class GetMigrationDataByIndexHandler @Inject constructor( if (jsMessage.id.isNullOrEmpty()) return val index = jsMessage.params.optInt(INDEX, -1) val value = standaloneDuckChatStore.getMigrationItemByIndex(index) - val jsonPayload = JSONObject() - if (value == null) { - jsonPayload.put(OK, false) - jsonPayload.put(REASON, "nothing at index: $index") - } else { - jsonPayload.put(OK, true) - jsonPayload.put(SERIALIZED_MIGRATION_FILE, value) + + val payload = JSONObject().apply { + if (value == null) { + put(OK, false) + put(REASON, "nothing at index: $index") + } else { + put(OK, true) + put(SERIALIZED_MIGRATION_FILE, value) + } } - jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)) + jsMessaging.onResponse(JsCallbackData(payload, featureName, jsMessage.method, jsMessage.id!!)) } override val allowedDomains: List = diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt index d7f321ada7f4..096c9d338841 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewClient.kt @@ -22,7 +22,6 @@ import android.webkit.WebViewClient import androidx.annotation.UiThread import com.duckduckgo.browser.api.JsInjectorPlugin import com.duckduckgo.common.utils.plugins.PluginPoint -import logcat.logcat import javax.inject.Inject class DuckChatWebViewClient @Inject constructor( @@ -39,12 +38,4 @@ class DuckChatWebViewClient @Inject constructor( it.onPageStarted(webView, url, null) } } - - override fun onPageFinished( - view: WebView?, - url: String?, - ) { - logcat { "Marcos url is $url and ${view?.url}" } - super.onPageFinished(view, url) - } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt index 56a555aa080b..483bf9a5b7f9 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt @@ -605,17 +605,25 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c } fun onBackPressed(): Boolean { - if (isVisible) { - if (simpleWebview.canGoBack()) { - simpleWebview.goBack() - } else { - hideSoftKeyboard() - duckChat.closeDuckChat() - } + if (!isVisible) return false + + if (!simpleWebview.canGoBack()) { + exit() return true + } + + val history = simpleWebview.copyBackForwardList() + if (viewModel.shouldCloseDuckChat(history)) { + exit() } else { - return false + simpleWebview.goBack() } + return true + } + + private fun exit() { + hideSoftKeyboard() + duckChat.closeDuckChat() } override fun continueDownload(pendingFileDownload: PendingFileDownload) { diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt index 33716b37fc5e..55f092bdba9d 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModel.kt @@ -16,12 +16,14 @@ package com.duckduckgo.duckchat.impl.ui +import android.webkit.WebBackForwardList import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.common.utils.AppUrl import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI import com.duckduckgo.duckchat.impl.DuckChatInternal -import com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsViewModel.ViewState import com.duckduckgo.subscriptions.api.Subscriptions import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel @@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn +import okhttp3.HttpUrl.Companion.toHttpUrl import javax.inject.Inject @ContributesViewModel(FragmentScope::class) @@ -67,7 +70,17 @@ class DuckChatWebViewViewModel @Inject constructor( fun handleOnSameWebView(url: String): Boolean { // Allow Duck.ai links to load within the same WebView (in-sheet navigation) - return duckChat.isDuckAiUrl(url) + return duckChat.canHandleOnAiWebView(url) + } + + fun shouldCloseDuckChat(history: WebBackForwardList): Boolean { + return runCatching { + if (!duckChat.isStandaloneMigrationEnabled()) return false + val currentItem = history.currentItem?.url + val firstItem = history.getItemAtIndex(0).url + currentItem?.toHttpUrl()?.topPrivateDomain() == HOST_DUCK_AI && + firstItem.toHttpUrl().topPrivateDomain() == AppUrl.Url.HOST + }.getOrElse { false } } private fun observeSubscriptionChanges() { diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModelTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModelTest.kt index fc8398c596fc..72635e34254a 100644 --- a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModelTest.kt +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewViewModelTest.kt @@ -16,6 +16,8 @@ package com.duckduckgo.duckchat.impl.ui +import android.webkit.WebBackForwardList +import android.webkit.WebHistoryItem import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule @@ -36,7 +38,9 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) @@ -188,4 +192,67 @@ class DuckChatWebViewViewModelTest { assertFalse(state.isFullScreenModeEnabled) } } + + @Test + fun `when handle on same webview then call duck chat`() { + viewModel.handleOnSameWebView("https://duck.ai/somepath") + verify(duckChat).canHandleOnAiWebView("https://duck.ai/somepath") + } + + @Test + fun `when should close duck chat and current is duck ai and first is duckduckgo then return true`() { + whenever(duckChat.isStandaloneMigrationEnabled()).thenReturn(true) + val history = mock() + + val currentItem = mock { + on { url } doReturn "https://duck.ai/somepath" + } + val firstItem = mock { + on { url } doReturn "https://duckduckgo.com/somepath" + } + whenever(history.currentItem).thenReturn(currentItem) + whenever(history.getItemAtIndex(0)).thenReturn(firstItem) + assertTrue(viewModel.shouldCloseDuckChat(history)) + } + + @Test + fun `when should close duck chat and current is not duck ai or first is no duckduckgo then return false`() { + whenever(duckChat.isStandaloneMigrationEnabled()).thenReturn(true) + val history = mock() + + var currentItem = mock { + on { url } doReturn "https://duckduckgo.com/somepath" + } + var firstItem = mock { + on { url } doReturn "https://duckduckgo.com/somepath" + } + whenever(history.currentItem).thenReturn(currentItem) + whenever(history.getItemAtIndex(0)).thenReturn(firstItem) + assertFalse(viewModel.shouldCloseDuckChat(history)) + currentItem = mock { + on { url } doReturn "https://duck.ai/somepath" + } + firstItem = mock { + on { url } doReturn "https://somesite.com" + } + whenever(history.currentItem).thenReturn(currentItem) + whenever(history.getItemAtIndex(0)).thenReturn(firstItem) + assertFalse(viewModel.shouldCloseDuckChat(history)) + } + + @Test + fun `when should close duck chat and feature flag is disabled then return false`() { + whenever(duckChat.isStandaloneMigrationEnabled()).thenReturn(false) + val history = mock() + + val currentItem = mock { + on { url } doReturn "https://duck.ai/somepath" + } + val firstItem = mock { + on { url } doReturn "https://duckduckgo.com/somepath" + } + whenever(history.currentItem).thenReturn(currentItem) + whenever(history.getItemAtIndex(0)).thenReturn(firstItem) + assertFalse(viewModel.shouldCloseDuckChat(history)) + } } 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 0f28cbc90520..6b26f23e3133 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 @@ -1118,6 +1118,14 @@ class RealDuckChatTest { assertTrue(url == "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=5") } + @Test + fun `when url can be handled by webview return true`() { + assertTrue(testee.canHandleOnAiWebView("https://duck.ai/somepath")) + assertTrue(testee.canHandleOnAiWebView("https://duck.ai/somepath/someotherpath?test=1")) + assertTrue(testee.canHandleOnAiWebView("https://duck.ai")) + assertTrue(testee.canHandleOnAiWebView("https://duckduckgo.com/revoke-duckai-access")) + } + companion object { val SETTINGS_JSON = """ { 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 9e0adbab82f0..782a43d67a2b 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 @@ -59,7 +59,6 @@ class RealDuckChatJSHelperTest { dataStore = mockDataStore, duckChatPixels = mockDuckChatPixels, duckAiMetricCollector = mockDuckAiMetricCollector, - dispatchers = coroutineRule.testDispatcherProvider, ) @Test @@ -258,6 +257,35 @@ class RealDuckChatJSHelperTest { assertEquals(expected.params.toString(), result.params.toString()) } + @Test + fun whenGetAIChatNativeConfigValuesAnStandaloneMigrationEnabledThenReturnJsCallbackDataWithCorrectData() = runTest { + val featureName = "aiChat" + val method = "getAIChatNativeConfigValues" + val id = "123" + + whenever(mockDuckChat.isStandaloneMigrationEnabled()).thenReturn(true) + + val result = testee.processJsCallbackMessage(featureName, method, id, null) + + val jsonPayload = JSONObject().apply { + put("platform", "android") + put("isAIChatHandoffEnabled", false) + put("supportsClosingAIChat", true) + put("supportsOpeningSettings", true) + put("supportsNativeChatInput", false) + put("supportsURLChatIDRestoration", false) + put("supportsImageUpload", false) + put("supportsStandaloneMigration", true) + } + + val expected = JsCallbackData(jsonPayload, featureName, method, id) + + assertEquals(expected.id, result!!.id) + assertEquals(expected.method, result.method) + assertEquals(expected.featureName, result.featureName) + assertEquals(expected.params.toString(), result.params.toString()) + } + @Test fun whenOpenAIChatAndHasPayloadThenUpdateStoreAndOpenDuckChat() = runTest { val featureName = "aiChat" @@ -542,90 +570,4 @@ class RealDuckChatJSHelperTest { assertEquals("openDuckAiSettings", result.subscriptionName) assertEquals(DUCK_CHAT_FEATURE_NAME, result.featureName) } - - @Test - fun whenStoreMigrationDataThenItemIsStoredAndInfoCountReflectsIt() = runTest { - val featureName = "aiChat" - val id = "1" - - // store two items - val item1 = JSONObject(mapOf("serializedMigrationFile" to "file-1")) - val item2 = JSONObject(mapOf("serializedMigrationFile" to "file-2")) - testee.processJsCallbackMessage(featureName, "storeMigrationData", id, item1) - testee.processJsCallbackMessage(featureName, "storeMigrationData", id, item2) - - // get count - val info = testee.processJsCallbackMessage(featureName, "getMigrationInfo", id, null) - val expected = JSONObject().apply { - put("ok", true) - put("count", 2) - } - assertEquals(expected.toString(), info!!.params.toString()) - } - - @Test - fun whenGetMigrationDataByIndexWithValidIndexThenReturnItem() = runTest { - val featureName = "aiChat" - val id = "1" - - // store items - testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-1"))) - testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-2"))) - - val result = testee.processJsCallbackMessage(featureName, "getMigrationDataByIndex", id, JSONObject(mapOf("index" to 1))) - val expected = JSONObject().apply { - put("ok", true) - put("serializedMigrationFile", "file-2") - } - assertEquals(expected.toString(), result!!.params.toString()) - } - - @Test - fun whenGetMigrationDataByIndexWithInvalidIndexThenReturnEmptyPayload() = runTest { - val featureName = "aiChat" - val id = "1" - - // store one item - testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-1"))) - - // negative index - val negative = testee.processJsCallbackMessage(featureName, "getMigrationDataByIndex", id, JSONObject(mapOf("index" to -1))) - val expectedNegative = JSONObject().apply { - put("ok", false) - put("reason", "nothing at index: -1") - } - assertEquals(expectedNegative.toString(), negative!!.params.toString()) - - // out of range index - val outOfRange = testee.processJsCallbackMessage(featureName, "getMigrationDataByIndex", id, JSONObject(mapOf("index" to 5))) - val expectedOutOfRange = JSONObject().apply { - put("ok", false) - put("reason", "nothing at index: 5") - } - assertEquals(expectedOutOfRange.toString(), outOfRange!!.params.toString()) - } - - @Test - fun whenClearMigrationDataThenItemsRemovedAndCountZero() = runTest { - val featureName = "aiChat" - val id = "1" - - // store items - testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-1"))) - testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-2"))) - - // clear - val clearResult = testee.processJsCallbackMessage(featureName, "clearMigrationData", id, null) - // clear returns ok true - val expectedClear = JSONObject().apply { put("ok", true) } - assertEquals(expectedClear.toString(), clearResult!!.params.toString()) - - // count is zero - val info = testee.processJsCallbackMessage(featureName, "getMigrationInfo", id, null) - val expected = JSONObject().apply { - put("ok", true) - put("count", 0) - } - assertEquals(expected.toString(), info!!.params.toString()) - } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/ClearMigrationDataHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/ClearMigrationDataHandlerTest.kt new file mode 100644 index 000000000000..8f6e2eedb260 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/ClearMigrationDataHandlerTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 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.messaging + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.duckchat.impl.messaging.fakes.FakeJsMessaging +import com.duckduckgo.js.messaging.api.JsMessage +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ClearMigrationDataHandlerTest { + private val standaloneStore = InMemoryStandaloneStore() + private val plugin = ClearMigrationDataHandler(standaloneStore) + private val handler = plugin.getJsMessageHandler() + private lateinit var messaging: FakeJsMessaging + + @Before + fun setup() { + messaging = FakeJsMessaging() + } + + @After + fun tearDown() { + standaloneStore.clearMigrationItems() + } + + @Test + fun `when clear migration data then items are removed`() { + standaloneStore.storeMigrationItem("Blob1") + standaloneStore.storeMigrationItem("Blob2") + assertEquals(2, standaloneStore.getMigrationItemCount()) + + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "123", + params = JSONObject(), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + + assertEquals(0, standaloneStore.getMigrationItemCount()) + assertEquals(handler.featureName, data!!.featureName) + assertEquals(handler.methods.first(), data.method) + assertEquals("123", data.id) + assertTrue(data.params.has("ok")) + assertTrue(data.params.getBoolean("ok")) + } + + @Test + fun `when clear migration data and no id then do nothing`() { + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "", + params = JSONObject(), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + assertNull(data) + } + + @Test + fun `only allow duckduckgo dot com domains`() { + val domains = handler.allowedDomains + assertTrue(domains.size == 2) + assertTrue(domains[0] == "duckduckgo.com") + assertTrue(domains[1] == "duck.ai") + } + + @Test + fun `feature name is ai chat`() { + assertTrue(handler.featureName == "aiChat") + } + + @Test + fun `only contains valid methods`() { + val methods = handler.methods + assertTrue(methods.size == 1) + assertTrue(methods[0] == "clearMigrationData") + } +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt index ba07ccd56abf..d742f9223f1e 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt @@ -41,7 +41,7 @@ class DuckChatContentScopeJsMessageHandlerTest { @Test fun `only contains valid methods`() { val methods = handler.methods - assertTrue(methods.size == 14) + assertTrue(methods.size == 10) assertTrue(methods[0] == "getAIChatNativeHandoffData") assertTrue(methods[1] == "getAIChatNativeConfigValues") assertTrue(methods[2] == "openAIChat") @@ -52,10 +52,6 @@ class DuckChatContentScopeJsMessageHandlerTest { assertTrue(methods[7] == "showChatInput") assertTrue(methods[8] == "reportMetric") assertTrue(methods[9] == "openKeyboard") - assertTrue(methods[10] == "storeMigrationData") - assertTrue(methods[11] == "getMigrationInfo") - assertTrue(methods[12] == "getMigrationDataByIndex") - assertTrue(methods[13] == "clearMigrationData") } private val callback = object : JsMessageCallback() { diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/GetMigrationDataByIndexHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/GetMigrationDataByIndexHandlerTest.kt new file mode 100644 index 000000000000..a99e95592b51 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/GetMigrationDataByIndexHandlerTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 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.messaging + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.duckchat.impl.messaging.fakes.FakeJsMessaging +import com.duckduckgo.js.messaging.api.JsMessage +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GetMigrationDataByIndexHandlerTest { + private val standaloneStore = InMemoryStandaloneStore() + private val plugin = GetMigrationDataByIndexHandler(standaloneStore) + private val handler = plugin.getJsMessageHandler() + private lateinit var messaging: FakeJsMessaging + + @Before + fun setup() { + messaging = FakeJsMessaging() + } + + @After + fun tearDown() { + standaloneStore.clearMigrationItems() + } + + @Test + fun `when get migration data by index, then return item`() { + standaloneStore.storeMigrationItem("Blob1") + + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "123", + params = JSONObject(mapOf("index" to 0)), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + + assertEquals(handler.featureName, data!!.featureName) + assertEquals(handler.methods.first(), data.method) + assertEquals("123", data.id) + assertTrue(data.params.has("ok")) + assertTrue(data.params.getBoolean("ok")) + assertTrue(data.params.has("serializedMigrationFile")) + assertEquals("Blob1", data.params.getString("serializedMigrationFile")) + } + + @Test + fun `when get migration data by index and index is invalid, return empty payload`() { + standaloneStore.storeMigrationItem("Blob1") + + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "123", + params = JSONObject(mapOf("index" to 1)), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + + assertEquals(handler.featureName, data!!.featureName) + assertEquals(handler.methods.first(), data.method) + assertEquals("123", data.id) + assertTrue(data.params.has("ok")) + assertFalse(data.params.getBoolean("ok")) + assertFalse(data.params.has("serializedMigrationFile")) + } + + @Test + fun `when get migration data by index and no id then do nothing`() { + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "", + params = JSONObject(), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + assertNull(data) + } + + @Test + fun `only allow duckduckgo dot com domains`() { + val domains = handler.allowedDomains + assertTrue(domains.size == 2) + assertTrue(domains[0] == "duckduckgo.com") + assertTrue(domains[1] == "duck.ai") + } + + @Test + fun `feature name is ai chat`() { + assertTrue(handler.featureName == "aiChat") + } + + @Test + fun `only contains valid methods`() { + val methods = handler.methods + assertTrue(methods.size == 1) + assertTrue(methods[0] == "getMigrationDataByIndex") + } +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/GetMigrationInfoHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/GetMigrationInfoHandlerTest.kt new file mode 100644 index 000000000000..b1bce4ea68f0 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/GetMigrationInfoHandlerTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 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.messaging + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.duckchat.impl.messaging.fakes.FakeJsMessaging +import com.duckduckgo.js.messaging.api.JsMessage +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GetMigrationInfoHandlerTest { + private val standaloneStore = InMemoryStandaloneStore() + private val plugin = GetMigrationInfoHandler(standaloneStore) + private val handler = plugin.getJsMessageHandler() + private lateinit var messaging: FakeJsMessaging + + @Before + fun setup() { + messaging = FakeJsMessaging() + } + + @After + fun tearDown() { + standaloneStore.clearMigrationItems() + } + + @Test + fun `when get migration info, then return count`() { + standaloneStore.storeMigrationItem("Blob1") + standaloneStore.storeMigrationItem("Blob2") + + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "123", + params = JSONObject(), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + + assertEquals(handler.featureName, data!!.featureName) + assertEquals(handler.methods.first(), data.method) + assertEquals("123", data.id) + assertTrue(data.params.has("ok")) + assertTrue(data.params.getBoolean("ok")) + assertTrue(data.params.has("count")) + assertEquals(2, data.params.getInt("count")) + } + + @Test + fun `when get migration info and nothing stored, then return count`() { + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "123", + params = JSONObject(), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + + assertEquals(handler.featureName, data!!.featureName) + assertEquals(handler.methods.first(), data.method) + assertEquals("123", data.id) + assertTrue(data.params.has("ok")) + assertTrue(data.params.getBoolean("ok")) + assertTrue(data.params.has("count")) + assertEquals(0, data.params.getInt("count")) + } + + @Test + fun `when get migration info and no id then do nothing`() { + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "", + params = JSONObject(), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + assertNull(data) + } + + @Test + fun `only allow duckduckgo dot com domains`() { + val domains = handler.allowedDomains + assertTrue(domains.size == 2) + assertTrue(domains[0] == "duckduckgo.com") + assertTrue(domains[1] == "duck.ai") + } + + @Test + fun `feature name is ai chat`() { + assertTrue(handler.featureName == "aiChat") + } + + @Test + fun `only contains valid methods`() { + val methods = handler.methods + assertTrue(methods.size == 1) + assertTrue(methods[0] == "getMigrationInfo") + } +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/InMemoryStandaloneStoreTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/InMemoryStandaloneStoreTest.kt new file mode 100644 index 000000000000..a98c5ce1345b --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/InMemoryStandaloneStoreTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 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.messaging + +import org.junit.Assert.* +import org.junit.Test + +class InMemoryStandaloneStoreTest { + private val store = InMemoryStandaloneStore() + + @Test + fun `when storeMigrationItem is called, message is stored`() { + val item1 = "Blob1" + val item2 = "Blob2" + store.storeMigrationItem(item1) + store.storeMigrationItem(item2) + + assertEquals(2, store.getMigrationItemCount()) + assertEquals(item1, store.getMigrationItemByIndex(0)) + assertEquals(item2, store.getMigrationItemByIndex(1)) + } + + @Test + fun `when clearMigrationItems is called, all messages are cleared`() { + val item1 = "Blob1" + store.storeMigrationItem(item1) + store.clearMigrationItems() + + assertEquals(0, store.getMigrationItemCount()) + assertNull(store.getMigrationItemByIndex(0)) + } +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/StoreMigrationDataHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/StoreMigrationDataHandlerTest.kt new file mode 100644 index 000000000000..890ba0ac140e --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/StoreMigrationDataHandlerTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 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.messaging + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.duckchat.impl.messaging.fakes.FakeJsMessaging +import com.duckduckgo.js.messaging.api.JsMessage +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StoreMigrationDataHandlerTest { + + private val standaloneStore = InMemoryStandaloneStore() + private val plugin = StoreMigrationDataHandler(standaloneStore) + private val handler = plugin.getJsMessageHandler() + private lateinit var messaging: FakeJsMessaging + + @Before + fun setup() { + messaging = FakeJsMessaging() + } + + @After + fun tearDown() { + standaloneStore.clearMigrationItems() + } + + @Test + fun `when store migration data message received, then data stored and response sent`() { + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "123", + params = JSONObject(mapOf("serializedMigrationFile" to "file-1")), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + + assertEquals(1, standaloneStore.getMigrationItemCount()) + assertEquals(handler.featureName, data!!.featureName) + assertEquals(handler.methods.first(), data.method) + assertEquals("123", data.id) + assertTrue(data.params.has("ok")) + assertTrue(data.params.getBoolean("ok")) + } + + @Test + fun `when store migration data message received and id does not exist then nothing sent back`() { + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "", + params = JSONObject(mapOf("serializedMigrationFile" to "file-1")), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + + assertEquals(0, standaloneStore.getMigrationItemCount()) + assertNull(data) + } + + @Test + fun `when store migration data message received but data does not exist then send failure response`() { + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "123", + params = JSONObject(), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + + assertEquals(0, standaloneStore.getMigrationItemCount()) + assertEquals(handler.featureName, data!!.featureName) + assertEquals(handler.methods.first(), data.method) + assertEquals("123", data.id) + assertTrue(data.params.has("ok")) + assertFalse(data.params.getBoolean("ok")) + } + + @Test + fun `when store migration data message received but data exist and is null then send failure response`() { + val message = JsMessage( + context = "context", + featureName = handler.featureName, + method = handler.methods.first(), + id = "123", + params = JSONObject(mapOf("serializedMigrationFile" to null)), + ) + + handler.process(message, messaging, null) + + val data = messaging.getLastResponse() + + assertEquals(0, standaloneStore.getMigrationItemCount()) + assertEquals(handler.featureName, data!!.featureName) + assertEquals(handler.methods.first(), data.method) + assertEquals("123", data.id) + assertTrue(data.params.has("ok")) + assertFalse(data.params.getBoolean("ok")) + } + + @Test + fun `only allow duckduckgo dot com domains`() { + val domains = handler.allowedDomains + assertTrue(domains.size == 2) + assertTrue(domains[0] == "duckduckgo.com") + assertTrue(domains[1] == "duck.ai") + } + + @Test + fun `feature name is ai chat`() { + assertTrue(handler.featureName == "aiChat") + } + + @Test + fun `only contains valid methods`() { + val methods = handler.methods + assertTrue(methods.size == 1) + assertTrue(methods[0] == "storeMigrationData") + } +} From 10ec9f3b5337a7125bf7c549dc4dea09489e38d1 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 25 Nov 2025 09:59:28 +0000 Subject: [PATCH 6/6] Flush --- .../duckchat/impl/ui/DuckChatWebViewFragment.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt index 483bf9a5b7f9..6f5890e9237f 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt @@ -30,6 +30,7 @@ import android.provider.MediaStore import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager +import android.webkit.CookieManager import android.webkit.MimeTypeMap import android.webkit.ValueCallback import android.webkit.WebChromeClient @@ -47,7 +48,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -131,9 +131,6 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c @Inject lateinit var duckChatJSHelper: DuckChatJSHelper - @Inject - lateinit var duckDuckGoUrlDetector: DuckDuckGoUrlDetector - @Inject lateinit var subscriptionsHandler: SubscriptionsHandler @@ -180,6 +177,8 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c @Inject lateinit var browserAndInputScreenTransitionProvider: BrowserAndInputScreenTransitionProvider + private val cookieManager: CookieManager by lazy { CookieManager.getInstance() } + private var pendingFileDownload: PendingFileDownload? = null private val downloadMessagesJob = ConflatedJob() @@ -752,9 +751,15 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c override fun onPause() { downloadMessagesJob.cancel() simpleWebview.onPause() + cookieManager.flush() super.onPause() } + override fun onDestroyView() { + super.onDestroyView() + cookieManager.flush() + } + companion object { private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200 private const val CUSTOM_UA =